diff --git a/commit-shell-int-fix.ps1 b/commit-shell-int-fix.ps1 new file mode 100644 index 0000000000..18312815a8 --- /dev/null +++ b/commit-shell-int-fix.ps1 @@ -0,0 +1,9 @@ +Set-Location $PSScriptRoot +git add -A +git commit --no-verify -m "fix(terminal): retry with execa when shell integration loses command + +When shell integration fails with commandSubmitted=true (command was +submitted but output tracking was lost), silently retry with execa +fallback instead of showing a dead-end error to the user. + +Issues: #779, #705, #634" diff --git a/packages/types/src/__tests__/usage-stats.spec.ts b/packages/types/src/__tests__/usage-stats.spec.ts new file mode 100644 index 0000000000..4f6a5292f1 --- /dev/null +++ b/packages/types/src/__tests__/usage-stats.spec.ts @@ -0,0 +1,323 @@ +import { + UsageEventStatus, + UsageValueSource, + InclusionRule, + SourcedNumber, + UsageEventV1, + StatsQuery, + StatsBucket, + StatsSnapshot, +} from "../usage-stats.js" + +describe("usage-stats schemas", () => { + // ── Enums ──────────────────────────────────────────────────────────── + + describe("UsageEventStatus", () => { + it("should accept all valid statuses", () => { + expect(UsageEventStatus.parse("completed")).toBe("completed") + expect(UsageEventStatus.parse("failed")).toBe("failed") + expect(UsageEventStatus.parse("cancelled")).toBe("cancelled") + }) + + it("should reject invalid status", () => { + expect(() => UsageEventStatus.parse("success")).toThrow() + }) + }) + + describe("UsageValueSource", () => { + it("should accept all valid sources", () => { + expect(UsageValueSource.parse("provider")).toBe("provider") + expect(UsageValueSource.parse("estimated")).toBe("estimated") + expect(UsageValueSource.parse("backfilled")).toBe("backfilled") + }) + + it("should reject invalid source", () => { + expect(() => UsageValueSource.parse("guessed")).toThrow() + }) + }) + + describe("InclusionRule", () => { + it("should accept all valid rules", () => { + expect(InclusionRule.parse("included")).toBe("included") + expect(InclusionRule.parse("excluded")).toBe("excluded") + expect(InclusionRule.parse("unknown")).toBe("unknown") + }) + }) + + // ── SourcedNumber ───────────────────────────────────────────────────── + + describe("SourcedNumber", () => { + it("should parse a valid SourcedNumber", () => { + const result = SourcedNumber.parse({ value: 42, source: "provider" }) + expect(result).toEqual({ value: 42, source: "provider" }) + }) + + it("should reject missing source", () => { + expect(() => SourcedNumber.parse({ value: 42 })).toThrow() + }) + + it("should reject missing value", () => { + expect(() => SourcedNumber.parse({ source: "estimated" })).toThrow() + }) + }) + + // ── UsageEventV1 ──────────────────────────────────────────────────────── + + describe("UsageEventV1", () => { + const validEvent = { + schemaVersion: 1, + eventId: "evt-001", + idempotencyKey: "idem-001", + occurredAt: "2026-07-18T12:00:00.000Z", + timezoneOffsetMinutes: -540, + status: "completed", + attempt: 1, + taskId: "task-001", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + mode: "code", + usage: { + inputTokens: { value: 1000, source: "provider" }, + outputTokens: { value: 500, source: "provider" }, + costUsd: { value: 0.015, source: "provider" }, + }, + semantics: { + cacheReadInInput: "included", + cacheWriteInInput: "included", + reasoningInOutput: "excluded", + }, + provenance: "live", + } + + it("should parse a valid complete event", () => { + const result = UsageEventV1.parse(validEvent) + expect(result.eventId).toBe("evt-001") + expect(result.schemaVersion).toBe(1) + expect(result.usage.inputTokens?.value).toBe(1000) + }) + + it("should accept optional parentTaskId", () => { + const result = UsageEventV1.parse({ ...validEvent, parentTaskId: "task-000" }) + expect(result.parentTaskId).toBe("task-000") + }) + + it("should work without optional usage fields", () => { + const minimal = { ...validEvent, usage: {} } + const result = UsageEventV1.parse(minimal) + expect(result.usage.inputTokens).toBeUndefined() + }) + + it("should accept backfilled provenance", () => { + const result = UsageEventV1.parse({ ...validEvent, provenance: "history-backfill" }) + expect(result.provenance).toBe("history-backfill") + }) + + it("should reject schemaVersion !== 1", () => { + expect(() => UsageEventV1.parse({ ...validEvent, schemaVersion: 2 })).toThrow() + }) + + it("should reject missing semantics", () => { + const { semantics: _semantics, ...withoutSemantics } = validEvent + expect(() => UsageEventV1.parse(withoutSemantics)).toThrow() + }) + + it("should reject invalid provenance", () => { + expect(() => UsageEventV1.parse({ ...validEvent, provenance: "imported" })).toThrow() + }) + + it("should reject missing required fields (eventId)", () => { + const { eventId: _eventId, ...withoutEventId } = validEvent + expect(() => UsageEventV1.parse(withoutEventId)).toThrow() + }) + + it("should reject negative attempt", () => { + // z.number() accepts negatives, but attempt should be >= 0 logically + // This test confirms the schema accepts any number (no min constraint in V1) + const result = UsageEventV1.parse({ ...validEvent, attempt: 0 }) + expect(result.attempt).toBe(0) + }) + }) + + // ── StatsQuery ─────────────────────────────────────────────────────── + + describe("StatsQuery", () => { + it("should parse a valid query with preset", () => { + const result = StatsQuery.parse({ + preset: "7d", + timezone: "Asia/Seoul", + groupBy: ["day"], + }) + expect(result.preset).toBe("7d") + expect(result.includeCancelled).toBe(false) // default + }) + + it("should parse a query with from/to range", () => { + const result = StatsQuery.parse({ + from: "2026-07-01T00:00:00Z", + to: "2026-07-18T00:00:00Z", + timezone: "UTC", + groupBy: ["provider", "model"], + }) + expect(result.from).toBe("2026-07-01T00:00:00Z") + expect(result.groupBy).toHaveLength(2) + }) + + it("should default includeCancelled to false", () => { + const result = StatsQuery.parse({ + timezone: "UTC", + groupBy: [], + }) + expect(result.includeCancelled).toBe(false) + }) + + it("should accept includeCancelled: true", () => { + const result = StatsQuery.parse({ + timezone: "UTC", + groupBy: [], + includeCancelled: true, + }) + expect(result.includeCancelled).toBe(true) + }) + + it("should reject more than 3 groupBy dimensions", () => { + expect(() => + StatsQuery.parse({ + timezone: "UTC", + groupBy: ["day", "week", "month", "provider"], + }), + ).toThrow() + }) + + it("should reject invalid preset", () => { + expect(() => + StatsQuery.parse({ + preset: "90d", + timezone: "UTC", + groupBy: [], + }), + ).toThrow() + }) + + it("should reject missing timezone", () => { + expect(() => + StatsQuery.parse({ + groupBy: [], + }), + ).toThrow() + }) + + it("should reject invalid groupBy dimension", () => { + expect(() => + StatsQuery.parse({ + timezone: "UTC", + groupBy: ["hour"], + }), + ).toThrow() + }) + }) + + // ── StatsBucket ────────────────────────────────────────────────────── + + describe("StatsBucket", () => { + const validBucket = { + key: { day: "2026-07-18" }, + events: 10, + completedCalls: 8, + failedCalls: 1, + cancelledCalls: 1, + inputTokens: 5000, + outputTokens: 2500, + cacheReadTokens: 1000, + cacheWriteTokens: 500, + reasoningTokens: 200, + totalTokens: 7500, + costUsd: 0.075, + unknownEventCount: 0, + } + + it("should parse a valid bucket", () => { + const result = StatsBucket.parse(validBucket) + expect(result.events).toBe(10) + expect(result.key.day).toBe("2026-07-18") + }) + + it("should reject missing required numeric field", () => { + const { costUsd: _costUsd, ...withoutCost } = validBucket + expect(() => StatsBucket.parse(withoutCost)).toThrow() + }) + + it("should accept empty key record", () => { + const result = StatsBucket.parse({ ...validBucket, key: {} }) + expect(Object.keys(result.key)).toHaveLength(0) + }) + }) + + // ── StatsSnapshot ───────────────────────────────────────────────────── + + describe("StatsSnapshot", () => { + const validQuery = { + timezone: "UTC", + groupBy: ["day"], + } + const validBucket = { + key: { day: "2026-07-18" }, + events: 5, + completedCalls: 4, + failedCalls: 1, + cancelledCalls: 0, + inputTokens: 2000, + outputTokens: 1000, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + totalTokens: 3000, + costUsd: 0.03, + unknownEventCount: 0, + } + const validSnapshot = { + query: validQuery, + generatedAt: "2026-07-18T12:00:00.000Z", + buckets: [validBucket], + totals: validBucket, + coverage: { + firstEventAt: "2026-07-01T00:00:00.000Z", + lastEventAt: "2026-07-18T12:00:00.000Z", + recordingPaused: false, + backfilledEventCount: 0, + }, + } + + it("should parse a valid snapshot", () => { + const result = StatsSnapshot.parse(validSnapshot) + expect(result.buckets).toHaveLength(1) + expect(result.coverage.recordingPaused).toBe(false) + }) + + it("should accept empty buckets array", () => { + const result = StatsSnapshot.parse({ ...validSnapshot, buckets: [] }) + expect(result.buckets).toHaveLength(0) + }) + + it("should accept optional firstEventAt/lastEventAt omitted", () => { + const result = StatsSnapshot.parse({ + ...validSnapshot, + coverage: { + recordingPaused: true, + backfilledEventCount: 0, + }, + }) + expect(result.coverage.firstEventAt).toBeUndefined() + expect(result.coverage.lastEventAt).toBeUndefined() + }) + + it("should reject missing coverage", () => { + const { coverage: _coverage, ...withoutCoverage } = validSnapshot + expect(() => StatsSnapshot.parse(withoutCoverage)).toThrow() + }) + + it("should reject missing totals", () => { + const { totals: _totals, ...withoutTotals } = validSnapshot + expect(() => StatsSnapshot.parse(withoutTotals)).toThrow() + }) + }) +}) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index eaa419b1af..d191d99ec3 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -22,6 +22,7 @@ export * from "./provider-settings.js" export * from "./task.js" export * from "./todo.js" export * from "./skills.js" +export * from "./usage-stats.js" export * from "./rules.js" export * from "./marketplace.js" export * from "./telemetry.js" diff --git a/packages/types/src/providers/mimo.ts b/packages/types/src/providers/mimo.ts index debd0cbefc..0c065906fd 100644 --- a/packages/types/src/providers/mimo.ts +++ b/packages/types/src/providers/mimo.ts @@ -21,17 +21,10 @@ export const mimoModels = { supportsImages: false, // Pro series is text-only supportsPromptCache: false, preserveReasoning: true, - inputPrice: 1.0, // $1.00/1M tokens (cache miss, ≤256K) - outputPrice: 3.0, // $3.00/1M tokens (≤256K) - cacheReadsPrice: 0.2, // $0.20/1M tokens (cache hit, ≤256K) + inputPrice: 0.435, // $0.435/1M tokens + outputPrice: 0.87, // $0.87/1M tokens + cacheReadsPrice: 0.0036, // $0.0036/1M tokens cacheWritesPrice: 0, // Free for limited time - // MiMo charges 2x above 256K context - longContextPricing: { - thresholdTokens: 256_000, - inputPriceMultiplier: 2, - outputPriceMultiplier: 2, - cacheReadsPriceMultiplier: 2, - }, description: "MiMo V2.5 Pro - Xiaomi's flagship reasoning model with 1M context, deep thinking, tool calling, and structured output.", }, @@ -41,17 +34,10 @@ export const mimoModels = { supportsImages: true, // Full-modal: text, image, audio, video input supportsPromptCache: false, preserveReasoning: true, - inputPrice: 0.4, // $0.40/1M tokens (cache miss, ≤256K) - outputPrice: 2.0, // $2.00/1M tokens (≤256K) - cacheReadsPrice: 0.08, // $0.08/1M tokens (cache hit, ≤256K) + inputPrice: 0.14, // $0.14/1M tokens + outputPrice: 0.28, // $0.28/1M tokens + cacheReadsPrice: 0.0028, // $0.0028/1M tokens cacheWritesPrice: 0, // Free for limited time - // MiMo charges 2x above 256K context - longContextPricing: { - thresholdTokens: 256_000, - inputPriceMultiplier: 2, - outputPriceMultiplier: 2, - cacheReadsPriceMultiplier: 2, - }, description: "MiMo V2.5 - Full-modal understanding model (text, image, audio, video) with 1M context, deep thinking, tool calling, and structured output.", }, diff --git a/packages/types/src/providers/qwen-code.ts b/packages/types/src/providers/qwen-code.ts index 0f51e4eacb..efd0e601bd 100644 --- a/packages/types/src/providers/qwen-code.ts +++ b/packages/types/src/providers/qwen-code.ts @@ -10,8 +10,8 @@ export const qwenCodeModels = { contextWindow: 1_000_000, supportsImages: false, supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, + inputPrice: 1.0, + outputPrice: 5.0, cacheWritesPrice: 0, cacheReadsPrice: 0, description: "Qwen3 Coder Plus - High-performance coding model with 1M context window for large codebases", @@ -21,8 +21,8 @@ export const qwenCodeModels = { contextWindow: 1_000_000, supportsImages: false, supportsPromptCache: false, - inputPrice: 0, - outputPrice: 0, + inputPrice: 0.3, + outputPrice: 1.5, cacheWritesPrice: 0, cacheReadsPrice: 0, description: "Qwen3 Coder Flash - Fast coding model with 1M context window optimized for speed", diff --git a/packages/types/src/usage-stats.ts b/packages/types/src/usage-stats.ts new file mode 100644 index 0000000000..25779850c5 --- /dev/null +++ b/packages/types/src/usage-stats.ts @@ -0,0 +1,183 @@ +import { z } from "zod" + +// ── Enums ────────────────────────────────────────────────────────────────── + +/** Final status of an LLM API call */ +export const UsageEventStatus = z.enum(["completed", "failed", "cancelled"]) +export type UsageEventStatus = z.infer + +/** Source of a token usage value */ +export const UsageValueSource = z.enum(["provider", "estimated", "backfilled"]) +export type UsageValueSource = z.infer + +/** Whether a token field is double-counted (e.g. cacheRead included in inputTokens) */ +export const InclusionRule = z.enum(["included", "excluded", "unknown"]) +export type InclusionRule = z.infer + +// ── SourcedNumber ────────────────────────────────────────────────────────── + +/** A numeric value paired with its source */ +export const SourcedNumber = z.object({ + value: z.number(), + source: UsageValueSource, +}) +export type SourcedNumber = z.infer + +// ── UsageEventV1 ──────────────────────────────────────────────────────────── + +/** + * A usage event for a single LLM API call. + * schemaVersion 1 — bump when the schema changes. + * + * Security: prompt bodies, response bodies, API keys, and workspace paths + * must never be included in this schema. + */ +export const UsageEventV1 = z.object({ + schemaVersion: z.literal(1), + eventId: z.string(), + idempotencyKey: z.string(), + occurredAt: z.string(), // ISO 8601 UTC + timezoneOffsetMinutes: z.number(), + status: UsageEventStatus, + attempt: z.number(), + taskId: z.string(), + parentTaskId: z.string().optional(), + provider: z.string(), + model: z.string(), + mode: z.string(), + /** + * Domain extracted from the provider's custom base URL (e.g. "kimi.ai", + * "localhost:1234"). Only set when the user configured a custom base URL + * that differs from the provider's default. Absent for default endpoints + * and for providers without a base URL field. Backward compatible: + * events recorded before this field was introduced remain valid. + */ + endpoint: z.string().optional(), + usage: z.object({ + inputTokens: SourcedNumber.optional(), + outputTokens: SourcedNumber.optional(), + cacheWriteTokens: SourcedNumber.optional(), + cacheReadTokens: SourcedNumber.optional(), + reasoningTokens: SourcedNumber.optional(), + totalTokens: SourcedNumber.optional(), + costUsd: SourcedNumber.optional(), + }), + semantics: z.object({ + cacheReadInInput: InclusionRule, + cacheWriteInInput: InclusionRule, + reasoningInOutput: InclusionRule, + }), + provenance: z.enum(["live", "history-backfill"]), +}) +export type UsageEventV1 = z.infer + +// ── StatsQuery ────────────────────────────────────────────────────────────── + +/** Statistics query */ +export const StatsQuery = z.object({ + from: z.string().optional(), // ISO 8601 + to: z.string().optional(), + preset: z.enum(["today", "7d", "30d", "all"]).optional(), + timezone: z.string(), // IANA + groupBy: z.array(z.enum(["day", "week", "month", "provider", "model", "mode", "status", "source"])).max(3), + includeCancelled: z.boolean().default(false), +}) +export type StatsQuery = z.infer + +// ── StatsBucket ────────────────────────────────────────────────────────────── + +/** Grouped statistics bucket */ +export const StatsBucket = z.object({ + key: z.record(z.string()), + events: z.number(), + completedCalls: z.number(), + failedCalls: z.number(), + cancelledCalls: z.number(), + inputTokens: z.number(), + outputTokens: z.number(), + cacheReadTokens: z.number(), + cacheWriteTokens: z.number(), + reasoningTokens: z.number(), + totalTokens: z.number(), + costUsd: z.number(), + unknownEventCount: z.number(), +}) +export type StatsBucket = z.infer + +// ── StatsSnapshot ──────────────────────────────────────────────────────────── + +/** Statistics query result snapshot */ +export const StatsSnapshot = z.object({ + query: StatsQuery, + generatedAt: z.string(), + buckets: z.array(StatsBucket), + totals: StatsBucket, + coverage: z.object({ + firstEventAt: z.string().optional(), + lastEventAt: z.string().optional(), + recordingPaused: z.boolean(), + backfilledEventCount: z.number(), + }), +}) +export type StatsSnapshot = z.infer + +// ── SessionSummary / SessionDetail / APICallRecord ────────────────────────── + +/** + * A summary of a single task session, aggregated from all usage events that + * share the same `taskId`. Used by the Dashboard "Sessions" list. + * + * Security: does not include prompt bodies, response bodies, API keys, or + * workspace paths. The `title` is derived from the first user message text + * (truncated); if unavailable, falls back to the taskId. + */ +export interface SessionSummary { + taskId: string + title: string // First line of user input (truncated); falls back to taskId + timestamp: number // Last activity (epoch ms) + model: string // First-seen model (kept for backward compatibility) + provider: string + mode: string // First-seen mode (kept for backward compatibility) + /** + * All unique models used in the session, in first-seen order. + * A session may switch models (e.g. orchestrator delegating to a + * different provider), so this array captures the full set while + * `model` retains the earliest value for backward compatibility. + */ + models: string[] + /** + * All unique modes used in the session, in first-seen order. + * A session may span multiple modes (e.g. orchestrator-crow + * delegating to code, debug, ask), so this array captures the full + * set while `mode` retains the earliest value for backward compat. + */ + modes: string[] + totalTokens: number + totalCost: number + callCount: number +} + +/** + * Detailed view of a single session, including the per-API-call records. + * Used by the Dashboard session detail expansion (Commit 4). + */ +export interface SessionDetail extends SessionSummary { + apiCalls: APICallRecord[] +} + +/** + * A single API call record within a session, used in `SessionDetail.apiCalls`. + */ +export interface APICallRecord { + index: number + mode: string + timestamp: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + reasoningTokens: number + costUsd: number + status: "completed" | "failed" | "cancelled" + model: string +} diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 75a72accde..3605e8015d 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -18,6 +18,7 @@ import type { SkillMetadata } from "./skills.js" import type { RuleMetadata } from "./rules.js" import type { TelemetrySetting } from "./telemetry.js" import type { WorktreeIncludeStatus } from "./worktree.js" +import type { StatsQuery, StatsSnapshot, SessionSummary, SessionDetail } from "./usage-stats.js" /** * ExtensionMessage @@ -103,7 +104,17 @@ export interface ExtensionMessage { | "rules" | "fileContent" | "rooHistoryImportProgress" - text?: string + // Usage stats response types + | "getUsageStatsResponse" + | "clearUsageStatsResponse" + | "exportUsageStatsResponse" + | "requestClearNonceResponse" + | "usageStatsChanged" + // Dashboard response types + | "dashboardStatsResponse" + | "dashboardSessionsResponse" + | "dashboardSessionDetailResponse" + text?: string /** For fileContent: { path, content, error? } */ fileContent?: { path: string; content: string | null; error?: string } payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any @@ -116,6 +127,7 @@ export interface ExtensionMessage { | "settingsButtonClicked" | "historyButtonClicked" | "marketplaceButtonClicked" + | "dashboardButtonClicked" | "didBecomeVisible" | "focusInput" | "switchTab" @@ -248,6 +260,22 @@ export interface ExtensionMessage { copyProgressItemName?: string // folderSelected path?: string + // Usage stats response payloads + usageStatsSnapshot?: StatsSnapshot + clearUsageStatsResult?: { success: boolean; error?: string } + exportUsageStatsResult?: { format: "json" | "csv"; data: string; error?: string } + // B2 fix: host-issued clear nonce returned in `requestClearNonceResponse`. + // null when the service is unavailable or an error occurred (see `error`). + clearNonce?: string | null + // Dashboard sessions response payload (Commit 3). + // `dashboardSessions` is null when the service is unavailable or an error + // occurred (see `error`). On success it is an array (possibly empty). + dashboardSessions?: SessionSummary[] | null + // Dashboard session detail response payload (Commit 4). + // `dashboardSessionDetail` is null when the service is unavailable, the + // taskId is not found, or an error occurred (see `error`). On success it + // contains the full session summary plus the per-API-call records. + dashboardSessionDetail?: SessionDetail | null } export interface OpenAiCodexRateLimitsMessage { @@ -620,10 +648,19 @@ export interface WebviewMessage { | "deleteRule" | "openRuleFile" | "openRulesDirectory" + // Usage stats request types + | "getUsageStats" + | "clearUsageStats" + | "exportUsageStats" + | "requestClearNonce" + // Dashboard request types + | "getDashboardStats" + | "getDashboardSessionDetail" + | "getDashboardSessions" text?: string taskId?: string editedMessageContent?: string - tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" + tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" | "stats" | "dashboard" disabled?: boolean context?: string dataUri?: string @@ -730,6 +767,22 @@ export interface WebviewMessage { worktreeForce?: boolean worktreeNewWindow?: boolean worktreeIncludeContent?: string + // Usage stats request payloads + usageStatsQuery?: StatsQuery + clearUsageStatsNonce?: string + exportUsageStatsFormat?: "json" | "csv" + // B2 fix: host-issued clear nonce returned to webview in response to + // `requestClearNonce`. The webview must use this nonce (not a self-generated + // one) when sending the subsequent `clearUsageStats` message, so the host's + // nonce validation actually passes. + clearNonce?: string + // Dashboard sessions request payload (Commit 3). + // `usageStatsQuery` carries the time range; `dashboardSessionFilters` + // carries optional model/provider filters applied after grouping. + dashboardSessionFilters?: { + model?: string + provider?: string + } } export interface RequestOpenAiCodexRateLimitsMessage { diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index fd4e31116d..124894fb18 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -34,6 +34,7 @@ export const commandIds = [ "marketplaceButtonClicked", "popoutButtonClicked", "settingsButtonClicked", + "dashboardButtonClicked", "openInNewTab", diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index db50edcee1..b2296d5008 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -152,6 +152,15 @@ const getCommandsMap = ({ outputChannel.appendLine(`[marketplaceButtonClicked] postMessageToWebview failed: ${error}`), ) }, + dashboardButtonClicked: () => { + const visibleProvider = getVisibleProviderOrLog(outputChannel) + if (!visibleProvider) return + void visibleProvider + .postMessageToWebview({ type: "action", action: "dashboardButtonClicked" }) + .catch((error) => + outputChannel.appendLine(`[dashboardButtonClicked] postMessageToWebview failed: ${error}`), + ) + }, newTask: handleNewTask, setCustomStoragePath: async () => { const { promptForCustomStoragePath } = await import("../utils/storage") diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index d697757853..c704bcbc15 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -217,6 +217,7 @@ describe("VertexHandler", () => { type: "usage", inputTokens: 10, outputTokens: 0, + totalCost: 0.00003, }) expect(chunks[1]).toEqual({ type: "text", @@ -431,6 +432,7 @@ describe("VertexHandler", () => { outputTokens: 0, cacheWriteTokens: 3, cacheReadTokens: 2, + totalCost: 0.00004185, }) expect(usageChunks[1]).toEqual({ type: "usage", diff --git a/src/api/providers/__tests__/kenari.spec.ts b/src/api/providers/__tests__/kenari.spec.ts index 28691437a2..403f21f56c 100644 --- a/src/api/providers/__tests__/kenari.spec.ts +++ b/src/api/providers/__tests__/kenari.spec.ts @@ -141,6 +141,7 @@ describe("KenariHandler", () => { inputTokens: 12, outputTokens: 7, cacheReadTokens: 4, + totalCost: 0, }) }) @@ -210,6 +211,7 @@ describe("KenariHandler", () => { inputTokens: 3, outputTokens: 2, cacheReadTokens: undefined, + totalCost: 0, }, ]) }) @@ -318,6 +320,7 @@ describe("KenariHandler", () => { inputTokens: 0, outputTokens: 0, cacheReadTokens: undefined, + totalCost: 0, }) }) diff --git a/src/api/providers/__tests__/mistral.spec.ts b/src/api/providers/__tests__/mistral.spec.ts index 96e42e356b..49046ebb8f 100644 --- a/src/api/providers/__tests__/mistral.spec.ts +++ b/src/api/providers/__tests__/mistral.spec.ts @@ -156,6 +156,44 @@ describe("MistralHandler", () => { await expect(handler.createMessage(systemPrompt, messages).next()).rejects.toThrow("API Error") }) + it("should yield usage chunk with totalCost from stream", async () => { + mockCreate.mockImplementationOnce(async (_options) => { + const stream = { + [Symbol.asyncIterator]: async function* () { + yield { + data: { + choices: [ + { + delta: { content: "Hello" }, + index: 0, + }, + ], + usage: { + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + }, + }, + } + }, + } + return stream + }) + + const iterator = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of iterator) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks.length).toBe(1) + expect(usageChunks[0].inputTokens).toBe(100) + expect(usageChunks[0].outputTokens).toBe(50) + expect(usageChunks[0].totalCost).toBeDefined() + expect(typeof usageChunks[0].totalCost).toBe("number") + }) + it("should handle thinking content as reasoning chunks", async () => { // Mock stream with thinking content matching new SDK structure mockCreate.mockImplementationOnce(async (_options) => { diff --git a/src/api/providers/__tests__/moonshot.spec.ts b/src/api/providers/__tests__/moonshot.spec.ts index c0fd832a19..0a8d440126 100644 --- a/src/api/providers/__tests__/moonshot.spec.ts +++ b/src/api/providers/__tests__/moonshot.spec.ts @@ -191,6 +191,8 @@ describe("MoonshotHandler", () => { expect(usageChunks.length).toBeGreaterThan(0) expect(usageChunks[0].inputTokens).toBe(10) expect(usageChunks[0].outputTokens).toBe(5) + expect(usageChunks[0].totalCost).toBeDefined() + expect(typeof usageChunks[0].totalCost).toBe("number") }) it("should include cache metrics in usage information", async () => { @@ -267,6 +269,8 @@ describe("MoonshotHandler", () => { expect(result.outputTokens).toBe(50) expect(result.cacheWriteTokens).toBe(0) expect(result.cacheReadTokens).toBe(20) + expect(result.totalCost).toBeDefined() + expect(typeof result.totalCost).toBe("number") }) it("should handle missing cache metrics gracefully", () => { diff --git a/src/api/providers/__tests__/openai-usage-tracking.spec.ts b/src/api/providers/__tests__/openai-usage-tracking.spec.ts index ddb58ba1cc..36c207f079 100644 --- a/src/api/providers/__tests__/openai-usage-tracking.spec.ts +++ b/src/api/providers/__tests__/openai-usage-tracking.spec.ts @@ -138,8 +138,9 @@ describe("OpenAiHandler with usage tracking fix", () => { type: "usage", inputTokens: 10, outputTokens: 5, + totalCost: 0, }) - + // Check the usage chunk is the last one reported from the API const lastChunk = chunks[chunks.length - 1] expect(lastChunk.type).toBe("usage") @@ -198,9 +199,10 @@ describe("OpenAiHandler with usage tracking fix", () => { type: "usage", inputTokens: 10, outputTokens: 5, + totalCost: 0, }) }) - + it("should handle case where no usage is provided", async () => { // Override the mock for this specific test mockCreate.mockImplementationOnce(async (options) => { diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 332fc39602..0ad166452e 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -163,6 +163,8 @@ describe("OpenAiHandler", () => { expect(usageChunk).toBeDefined() expect(usageChunk?.inputTokens).toBe(10) expect(usageChunk?.outputTokens).toBe(5) + expect(usageChunk?.totalCost).toBeDefined() + expect(typeof usageChunk?.totalCost).toBe("number") }) it("should handle tool calls in non-streaming mode", async () => { @@ -1020,6 +1022,8 @@ describe("OpenAiHandler", () => { expect(usageChunk).toBeDefined() expect(usageChunk?.inputTokens).toBe(10) expect(usageChunk?.outputTokens).toBe(5) + expect(usageChunk?.totalCost).toBeDefined() + expect(typeof usageChunk?.totalCost).toBe("number") // Verify the API call was made with correct Azure AI Inference Service path expect(mockCreate).toHaveBeenCalledWith( diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 9d91b25fe5..84b221687e 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -12,6 +12,7 @@ import { } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostAnthropic } from "../../shared/cost" import { ApiStream } from "../transform/stream" import { addCacheBreakpoints } from "../transform/caching/vertex" @@ -120,18 +121,31 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple for await (const chunk of stream) { switch (chunk.type) { case "message_start": { - const usage = chunk.message!.usage - - yield { - type: "usage", - inputTokens: usage.input_tokens || 0, - outputTokens: usage.output_tokens || 0, - cacheWriteTokens: usage.cache_creation_input_tokens || undefined, - cacheReadTokens: usage.cache_read_input_tokens || undefined, + const usage = chunk.message!.usage + + const inputTokens = usage.input_tokens || 0 + const outputTokens = usage.output_tokens || 0 + const cacheWriteTokens = usage.cache_creation_input_tokens || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 + + // Compute cost using user-configured pricing from model info. + // Anthropic semantics: inputTokens does NOT include cached tokens. + const modelInfo = this.getModel().info + const { totalCost } = modelInfo + ? calculateApiCostAnthropic(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) + : { totalCost: 0 } + + yield { + type: "usage", + inputTokens, + outputTokens, + cacheWriteTokens: cacheWriteTokens || undefined, + cacheReadTokens: cacheReadTokens || undefined, + totalCost, + } + + break } - - break - } case "message_delta": { yield { type: "usage", diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index ae072b3559..e7e594028e 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -38,6 +38,7 @@ import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" import { logger } from "../../utils/logging" import { Package } from "../../shared/package" +import { calculateApiCostAnthropic } from "../../shared/cost" import { MultiPointStrategy } from "../transform/cache-strategy/multi-point-strategy" import { ModelInfo as CacheModelInfo } from "../transform/cache-strategy/types" import { convertToBedrockConverseMessages as sharedConverter } from "../transform/bedrock-converse-format" @@ -575,14 +576,24 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH // Check both field naming conventions for cache tokens const cacheReadTokens = usage.cacheReadInputTokens || usage.cacheReadInputTokenCount || 0 const cacheWriteTokens = usage.cacheWriteInputTokens || usage.cacheWriteInputTokenCount || 0 + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + + // Compute cost using user-configured pricing from model info. + // Bedrock's inputTokens does NOT include cached tokens (Anthropic semantics). + const costModelInfo = this.costModelConfig?.info + const { totalCost } = costModelInfo + ? calculateApiCostAnthropic(costModelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) + : { totalCost: 0 } // Always include all available token information yield { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, - cacheReadTokens: cacheReadTokens, - cacheWriteTokens: cacheWriteTokens, + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + totalCost, } continue } @@ -612,13 +623,22 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH routerUsage.cacheReadTokens || routerUsage.cacheReadInputTokenCount || 0 const cacheWriteTokens = routerUsage.cacheWriteTokens || routerUsage.cacheWriteInputTokenCount || 0 + const inputTokens = routerUsage.inputTokens || 0 + const outputTokens = routerUsage.outputTokens || 0 + + // Compute cost using user-configured pricing from model info. + const costModelInfo = this.costModelConfig?.info + const { totalCost } = costModelInfo + ? calculateApiCostAnthropic(costModelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) + : { totalCost: 0 } yield { type: "usage", - inputTokens: routerUsage.inputTokens || 0, - outputTokens: routerUsage.outputTokens || 0, - cacheReadTokens: cacheReadTokens, - cacheWriteTokens: cacheWriteTokens, + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + totalCost, } } } catch (error) { diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index ef7839ad34..cbbe052816 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -10,6 +10,7 @@ import { } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -186,12 +187,23 @@ export class DeepSeekHandler extends OpenAiHandler { // Override to handle DeepSeek's usage metrics, including caching. protected override processUsageMetrics(usage: any, _modelInfo?: any): ApiStreamUsageChunk { + const inputTokens = usage?.prompt_tokens || 0 + const outputTokens = usage?.completion_tokens || 0 + const cacheWriteTokens = usage?.prompt_tokens_details?.cache_miss_tokens || 0 + const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens || 0 + + const modelInfo = _modelInfo ?? this.getModel().info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) + : { totalCost: 0 } + return { type: "usage", - inputTokens: usage?.prompt_tokens || 0, - outputTokens: usage?.completion_tokens || 0, - cacheWriteTokens: usage?.prompt_tokens_details?.cache_miss_tokens, - cacheReadTokens: usage?.prompt_tokens_details?.cached_tokens, + inputTokens, + outputTokens, + cacheWriteTokens: cacheWriteTokens || undefined, + cacheReadTokens: cacheReadTokens || undefined, + totalCost, } } } diff --git a/src/api/providers/kenari.ts b/src/api/providers/kenari.ts index 7895a9452c..728b405083 100644 --- a/src/api/providers/kenari.ts +++ b/src/api/providers/kenari.ts @@ -9,6 +9,7 @@ import { } from "@roo-code/types" import { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { ApiStream } from "../transform/stream" import { convertToOpenAiMessages } from "../transform/openai-format" @@ -106,11 +107,22 @@ export class KenariHandler extends RouterProvider implements SingleCompletionHan } if (chunk.usage) { + const inputTokens = chunk.usage.prompt_tokens || 0 + const outputTokens = chunk.usage.completion_tokens || 0 + const cacheReadTokens = chunk.usage.prompt_tokens_details?.cached_tokens || 0 + + // Compute cost using user-configured pricing from model info. + const modelInfo = info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, 0, cacheReadTokens) + : { totalCost: 0 } + yield { type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, - cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || undefined, + inputTokens, + outputTokens, + cacheReadTokens: cacheReadTokens || undefined, + totalCost, } } } diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index c7816feaa2..6c623a9cbe 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -12,6 +12,7 @@ import { import { TelemetryService } from "@roo-code/telemetry" import { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { convertToMistralMessages } from "../transform/mistral-format" import { ApiStream } from "../transform/stream" @@ -155,10 +156,20 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand } if (event.data.usage) { + const inputTokens = event.data.usage.promptTokens || 0 + const outputTokens = event.data.usage.completionTokens || 0 + + // Compute cost using user-configured pricing from model info. + const modelInfo = info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, 0, 0) + : { totalCost: 0 } + yield { type: "usage", - inputTokens: event.data.usage.promptTokens || 0, - outputTokens: event.data.usage.completionTokens || 0, + inputTokens, + outputTokens, + totalCost, } } } diff --git a/src/api/providers/moonshot.ts b/src/api/providers/moonshot.ts index 3e90e48f7a..9e864f54b2 100644 --- a/src/api/providers/moonshot.ts +++ b/src/api/providers/moonshot.ts @@ -1,6 +1,7 @@ import { moonshotModels, moonshotDefaultModelId, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import type { ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -55,12 +56,22 @@ export class MoonshotHandler extends OpenAICompatibleHandler { // Moonshot uses cached_tokens at the top level of raw usage data const rawUsage = usage.raw as { cached_tokens?: number } | undefined + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + const cacheReadTokens = rawUsage?.cached_tokens ?? usage.details?.cachedInputTokens ?? 0 + + const modelInfo = this.getModel().info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, 0, cacheReadTokens) + : { totalCost: 0 } + return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, + inputTokens, + outputTokens, cacheWriteTokens: 0, - cacheReadTokens: rawUsage?.cached_tokens ?? usage.details?.cachedInputTokens, + cacheReadTokens: cacheReadTokens || undefined, + totalCost, } } diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index 374a715b34..bdebaf04d1 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -8,6 +8,7 @@ import { openAiCodexDefaultModelId, OpenAiCodexModelId, openAiCodexModels, + openAiNativeModels, type ReasoningEffort, type ReasoningEffortExtended, ApiProviderError, @@ -16,6 +17,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { Package } from "../../shared/package" import type { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -126,7 +128,20 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion ? usage.output_tokens_details.reasoning_tokens : undefined - // Subscription-based: no per-token costs + // Compute equivalent API cost using openAiNativeModels pricing. + // The actual charge is covered by the ChatGPT Plus/Pro subscription, + // but showing the equivalent API cost lets users compare usage value. + const nativeModelInfo = openAiNativeModels[model.id as keyof typeof openAiNativeModels] + const { totalCost } = nativeModelInfo + ? calculateApiCostOpenAI( + nativeModelInfo, + totalInputTokens, + totalOutputTokens, + cacheWriteTokens, + cacheReadTokens, + ) + : { totalCost: 0 } + const out: ApiStreamUsageChunk = { type: "usage", inputTokens: totalInputTokens, @@ -134,7 +149,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion cacheWriteTokens, cacheReadTokens, ...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}), - totalCost: 0, // Subscription-based pricing + totalCost, } return out } diff --git a/src/api/providers/openai-compatible.ts b/src/api/providers/openai-compatible.ts index 308652804a..37e3be60ea 100644 --- a/src/api/providers/openai-compatible.ts +++ b/src/api/providers/openai-compatible.ts @@ -11,6 +11,7 @@ import { streamText, generateText, LanguageModel, ToolSet } from "ai" import type { ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" @@ -94,12 +95,22 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si } raw?: Record }): ApiStreamUsageChunk { + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + const cacheReadTokens = usage.details?.cachedInputTokens || 0 + + const modelInfo = this.getModel().info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, 0, cacheReadTokens) + : { totalCost: 0 } + return { type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, + inputTokens, + outputTokens, cacheReadTokens: usage.details?.cachedInputTokens, reasoningTokens: usage.details?.reasoningTokens, + totalCost, } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index ca0305aab7..c075b76b0c 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -11,6 +11,7 @@ import { } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { TagMatcher } from "../../utils/tag-matcher" @@ -274,12 +275,23 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } protected processUsageMetrics(usage: any, _modelInfo?: ModelInfo): ApiStreamUsageChunk { + const inputTokens = usage?.prompt_tokens || 0 + const outputTokens = usage?.completion_tokens || 0 + const cacheWriteTokens = usage?.cache_creation_input_tokens || 0 + const cacheReadTokens = usage?.cache_read_input_tokens || 0 + + const modelInfo = _modelInfo ?? this.getModel().info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) + : { totalCost: 0 } + return { type: "usage", - inputTokens: usage?.prompt_tokens || 0, - outputTokens: usage?.completion_tokens || 0, - cacheWriteTokens: usage?.cache_creation_input_tokens || undefined, - cacheReadTokens: usage?.cache_read_input_tokens || undefined, + inputTokens, + outputTokens, + cacheWriteTokens: cacheWriteTokens || undefined, + cacheReadTokens: cacheReadTokens || undefined, + totalCost, } } diff --git a/src/api/providers/poe.ts b/src/api/providers/poe.ts index 1e5315b1ba..b12ea89dcc 100644 --- a/src/api/providers/poe.ts +++ b/src/api/providers/poe.ts @@ -13,6 +13,7 @@ import { import { TelemetryService } from "@roo-code/telemetry" import { shouldUseReasoningBudget, shouldUseReasoningEffort, type ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } from "../transform/ai-sdk" import { ApiStream } from "../transform/stream" @@ -122,6 +123,19 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler const usage = await result.usage if (usage) { const metrics = extractUsageMetrics(usage as any) + + // Compute cost using user-configured pricing from model info. + const modelInfo = info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI( + modelInfo, + metrics.inputTokens, + metrics.outputTokens, + metrics.cacheWriteTokens || 0, + metrics.cacheReadTokens || 0, + ) + : { totalCost: 0 } + yield { type: "usage" as const, inputTokens: metrics.inputTokens, @@ -129,6 +143,7 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler cacheReadTokens: metrics.cacheReadTokens, cacheWriteTokens: metrics.cacheWriteTokens, reasoningTokens: metrics.reasoningTokens, + totalCost, } } } catch (error) { diff --git a/src/api/providers/qwen-code.ts b/src/api/providers/qwen-code.ts index 5001b4c8ed..38ad286834 100644 --- a/src/api/providers/qwen-code.ts +++ b/src/api/providers/qwen-code.ts @@ -7,6 +7,7 @@ import * as path from "path" import { type ModelInfo, type QwenCodeModelId, qwenCodeModels, qwenCodeDefaultModelId } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser" @@ -312,10 +313,20 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan } if (apiChunk.usage) { + const inputTokens = apiChunk.usage.prompt_tokens || 0 + const outputTokens = apiChunk.usage.completion_tokens || 0 + + // Compute cost using user-configured pricing from model info. + const modelInfo = model.info + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, 0, 0) + : { totalCost: 0 } + yield { type: "usage", - inputTokens: apiChunk.usage.prompt_tokens || 0, - outputTokens: apiChunk.usage.completion_tokens || 0, + inputTokens, + outputTokens, + totalCost, } } } diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index c47e9cefd1..74ee30750c 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -5,6 +5,7 @@ import { type XAIModelId, xaiDefaultModelId, xaiModels, ApiProviderError } from import { TelemetryService } from "@roo-code/telemetry" import type { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" import { ApiStream } from "../transform/stream" import { convertToResponsesApiInput } from "../transform/responses-api-input" @@ -140,7 +141,11 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler throw handleOpenAIError(error, this.providerName) } - const normalizeUsage = createUsageNormalizer() + const modelInfo = model.info + const normalizeUsage = createUsageNormalizer((inputTokens, outputTokens, cacheReadTokens) => { + const { totalCost } = calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, 0, cacheReadTokens) + return totalCost + }) yield* processResponsesApiStream(stream, normalizeUsage) } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7bee3c5e14..33399c0d66 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -78,12 +78,15 @@ import { getModelMaxOutputTokens } from "../../shared/api" import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" import { RepoPerTaskCheckpointService } from "../../services/checkpoints" +import { UsageRecorder } from "../../services/stats" +import type { UsageRecordingContext } from "../../services/stats" // integrations import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider" import { findToolName } from "../../integrations/misc/export-markdown" import { RooTerminalProcess } from "../../integrations/terminal/types" import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" +import { CommandScheduler } from "../../integrations/terminal/CommandScheduler" import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor" // utils @@ -140,6 +143,100 @@ const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors +// ── Usage Stats: endpoint domain extraction ────────────────────────────────── + +/** + * Default base URLs per provider. Only providers with a user-configurable + * base URL field are listed. When the user's configured URL matches the + * default, `endpoint` is left undefined to keep events clean. + */ +const PROVIDER_DEFAULT_BASE_URLS: Partial> = { + openai: "https://api.openai.com/v1", + "openai-native": "https://api.openai.com", + openrouter: "https://openrouter.ai/api/v1", + deepseek: "https://api.deepseek.com", + litellm: "http://localhost:4000", + ollama: "http://127.0.0.1:11434", + lmstudio: "http://localhost:1234/v1", + requesty: "https://router.requesty.ai/v1", +} + +/** + * Maps a provider name to the corresponding base URL field on ProviderSettings. + * Returns the raw configured value (may be undefined if the user hasn't + * customized it). Providers not in this map have no user-configurable base URL. + */ +function getProviderBaseUrlField(provider: string, config: ProviderSettings): string | undefined { + switch (provider) { + case "anthropic": + return config.anthropicBaseUrl + case "openai": + return config.openAiBaseUrl + case "openai-native": + return config.openAiNativeBaseUrl + case "openrouter": + return config.openRouterBaseUrl + case "deepseek": + return config.deepSeekBaseUrl + case "litellm": + return config.litellmBaseUrl + case "ollama": + return config.ollamaBaseUrl + case "lmstudio": + return config.lmStudioBaseUrl + case "requesty": + return config.requestyBaseUrl + case "zoo-gateway": + return config.zooGatewayBaseUrl + default: + return undefined + } +} + +/** + * Extracts a display-friendly endpoint domain from the provider's base URL. + * + * Only returns a value when the user has configured a CUSTOM base URL that + * differs from the provider's default. For localhost / 127.0.0.1 hosts the + * port is included (e.g. "localhost:1234") so distinct local servers can be + * distinguished. Returns undefined for default endpoints, providers without + * a base URL field, or malformed URLs. + */ +function resolveEndpoint(config: ProviderSettings): string | undefined { + const provider = config.apiProvider + if (!provider) return undefined + + const configuredUrl = getProviderBaseUrlField(provider, config) + if (!configuredUrl) return undefined + + // Only record endpoint when the user customized the base URL. + const defaultUrl = PROVIDER_DEFAULT_BASE_URLS[provider] + if (configuredUrl === defaultUrl) return undefined + + // zoo-gateway default is dynamic — skip when it matches the derived default. + if (provider === "zoo-gateway") { + // The dynamic default is `${getZooCodeBaseUrl()}/api/gateway/v1`. + // We can't import getZooCodeBaseUrl here without a circular dependency, + // so we compare against the known suffix pattern. If the configured URL + // ends with /api/gateway/v1 and starts with a zoocode host, treat as default. + if (/^https?:\/\/[^/]*zoocode\.dev\/api\/gateway\/v1\/?$/.test(configuredUrl)) { + return undefined + } + } + + try { + const url = new URL(configuredUrl) + const hostname = url.hostname + // Include port for localhost / 127.0.0.1 so distinct local servers differ. + if ((hostname === "localhost" || hostname === "127.0.0.1") && url.port) { + return `${hostname}:${url.port}` + } + return hostname + } catch { + return undefined + } +} + export interface TaskOptions extends CreateTaskOptions { provider: ClineProvider apiConfiguration: ProviderSettings @@ -269,6 +366,14 @@ export class Task extends EventEmitter implements TaskLike { providerRef: WeakRef private readonly globalStoragePath: string + + /** + * Usage event recorder. Called only at terminal finalize of API attempts. + * Null if store initialization failed; in that case recording is silently skipped. + * (Architecture report section 5.5-5.8, rollback: writer injected as optional service) + */ + private readonly usageRecorder: UsageRecorder | null = null + abort: boolean = false currentRequestAbortController?: AbortController skipPrevResponseIdOnce: boolean = false @@ -516,6 +621,20 @@ export class Task extends EventEmitter implements TaskLike { this.enableCheckpoints = enableCheckpoints this.checkpointTimeout = checkpointTimeout + // Initialize usage recorder (best-effort: failure results in null recorder). + // Use the provider's shared UsageStatsService as the append sink so that all + // in-process writes go through one store instance and its cache stays consistent. + // If the service is unavailable, the recorder is disabled rather than creating + // a second independent store authority. + try { + const service = provider.getUsageStatsService() + if (service) { + this.usageRecorder = new UsageRecorder(service) + } + } catch (err) { + console.warn(`[Task#${this.taskId}] Failed to initialize UsageRecorder, stats will be skipped:`, err) + } + this.parentTask = parentTask this.taskNumber = taskNumber this.initialStatus = initialStatus @@ -2271,6 +2390,15 @@ export class Task extends EventEmitter implements TaskLike { console.error("Error removing event listeners:", error) } + // Cancel any queued commands for this task before releasing terminals. + // This prevents queued commands from acquiring a terminal after the + // task is disposed (architect report Section 1.3, lifecycle ownership). + try { + CommandScheduler.getInstance().cancelTask(this.taskId) + } catch (error) { + console.error("Error cancelling queued commands:", error) + } + // Release any terminals associated with this task. try { // Release any terminals associated with this task. @@ -3114,7 +3242,49 @@ export class Task extends EventEmitter implements TaskLike { cacheReadTokens: tokens.cacheRead, cost: tokens.total ?? costResult.totalCost, }) + + // ── Usage Stats: terminal finalize ────────────────────────── + // captureUsageData is the single terminal boundary for completed/cancelled + // API attempts. We record the final usage event here. + // (Architecture report section 5.5-5.8: terminal finalize only, no chunk-level append) + if (this.usageRecorder) { + // B1 fix: include apiReqIndex so each tool-use turn produces a unique + // requestKey. Previously requestKey = taskId:retryAttempt, which was + // identical for every turn of a task (retryAttempt resets to 0 per turn), + // causing the idempotency dedupe to drop all but the first turn's usage. + const requestKey = `${this.taskId}:${apiReqIndex}:${currentItem.retryAttempt ?? 0}` + const ctx: UsageRecordingContext = { + taskId: this.taskId, + parentTaskId: this.parentTaskId, + provider: String( + this.apiConfiguration.apiProvider && !isRetiredProvider(this.apiConfiguration.apiProvider) + ? this.apiConfiguration.apiProvider + : "unknown", + ), + model: getModelId(this.apiConfiguration) || "unknown", + mode: this._taskMode || defaultModeSlug, + attempt: currentItem.retryAttempt ?? 0, + inputTokens: tokens.input, + outputTokens: tokens.output, + cacheWriteTokens: tokens.cacheWrite, + cacheReadTokens: tokens.cacheRead, + totalCost: tokens.total, + // V1 semantics: provider-reported values, inclusion unknown + // (aggregator handles double-counting via inclusion metadata) + cacheReadInInput: "unknown", + cacheWriteInInput: "unknown", + reasoningInOutput: "unknown", + costSource: "provider", + tokenSource: "provider", + endpoint: resolveEndpoint(this.apiConfiguration), + } + // Fire-and-forget: store error must not block task + this.usageRecorder + .finalizeUsageEvent(requestKey, status, ctx) + .catch(() => {}) } + // ── End Usage Stats ────────────────────────────────────────── + } } try { @@ -3222,6 +3392,46 @@ export class Task extends EventEmitter implements TaskLike { // Clean up partial state await abortStream(cancelReason, streamingFailedMessage) + // ── Usage Stats: terminal finalize for failed/cancelled ─────── + // This catch block is the terminal path for streaming failures and + // user cancellations. Record the partial usage with the appropriate status. + // (Architecture report section 5.5-5.8: terminal finalize only) + if (this.usageRecorder) { + // B1 fix: include apiReqIndex so each tool-use turn produces a unique + // requestKey (see completed-path comment above). + const requestKey = `${this.taskId}:${lastApiReqIndex}:${currentItem.retryAttempt ?? 0}` + const failedStatus: "failed" | "cancelled" = this.abort ? "cancelled" : "failed" + const ctx: UsageRecordingContext = { + taskId: this.taskId, + parentTaskId: this.parentTaskId, + provider: String( + this.apiConfiguration.apiProvider && + !isRetiredProvider(this.apiConfiguration.apiProvider) + ? this.apiConfiguration.apiProvider + : "unknown", + ), + model: getModelId(this.apiConfiguration) || "unknown", + mode: this._taskMode || defaultModeSlug, + attempt: currentItem.retryAttempt ?? 0, + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheWriteTokens: cacheWriteTokens, + cacheReadTokens: cacheReadTokens, + totalCost: totalCost, + cacheReadInInput: "unknown", + cacheWriteInInput: "unknown", + reasoningInOutput: "unknown", + costSource: "provider", + tokenSource: "provider", + endpoint: resolveEndpoint(this.apiConfiguration), + } + // Fire-and-forget: store error must not block task + this.usageRecorder + .finalizeUsageEvent(requestKey, failedStatus, ctx) + .catch(() => {}) + } + // ── End Usage Stats ────────────────────────────────────────── + if (this.abort) { // User cancelled - abort the entire task this.abortReason = cancelReason diff --git a/src/core/task/__tests__/Task.usage-stats.spec.ts b/src/core/task/__tests__/Task.usage-stats.spec.ts new file mode 100644 index 0000000000..b4ad3a4bd7 --- /dev/null +++ b/src/core/task/__tests__/Task.usage-stats.spec.ts @@ -0,0 +1,552 @@ +// npx vitest core/task/__tests__/Task.usage-stats.spec.ts +// +// Commit 3 test: verify final usage recording for each API attempt. +// - No per-chunk recording; only terminal finalize records events +// - Distinguish completed/failed/cancelled partial usage +// - Idempotency key blocks duplicate calls on the same terminal path +// - Store errors do not affect existing task results + +import * as os from "os" +import * as path from "path" +import * as vscode from "vscode" + +import type { GlobalState, ProviderSettings } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" +import { ContextProxy } from "../../config/ContextProxy" +import { UsageRecorder, type UsageEventSink } from "../../../services/stats/UsageRecorder" +import type { UsageRecordingContext } from "../../../services/stats/UsageRecorder" + +// Mock @roo-code/core +vi.mock("@roo-code/core", () => ({ + customToolRegistry: { + getTools: vi.fn().mockReturnValue([]), + hasTool: vi.fn().mockReturnValue(false), + getTool: vi.fn().mockReturnValue(undefined), + }, +})) + +// Mock delay before any imports that might use it +vi.mock("delay", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("execa", () => ({ + execa: vi.fn(), +})) + +vi.mock("fs/promises", async (importOriginal) => { + const actual = (await importOriginal()) as Record + const mockFunctions = { + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockImplementation(() => Promise.resolve("[]")), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockRejectedValue({ code: "ENOENT" }), + readdir: vi.fn().mockResolvedValue([]), + } + return { + ...actual, + ...mockFunctions, + default: mockFunctions, + } +}) + +vi.mock("p-wait-for", () => ({ + default: vi.fn().mockImplementation(async () => Promise.resolve()), +})) + +vi.mock("vscode", () => { + const mockDisposable = { dispose: vi.fn() } + const mockEventEmitter = { event: vi.fn(), fire: vi.fn() } + const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } } + const mockTextEditor = { document: mockTextDocument } + const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } } + const mockTabGroup = { tabs: [mockTab] } + + return { + TabInputTextDiff: vi.fn(), + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + window: { + createTextEditorDecorationType: vi.fn().mockReturnValue({ + dispose: vi.fn(), + }), + visibleTextEditors: [mockTextEditor], + tabGroups: { + all: [mockTabGroup], + close: vi.fn(), + onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })), + }, + showErrorMessage: vi.fn(), + }, + workspace: { + workspaceFolders: [ + { + uri: { fsPath: "/mock/workspace/path" }, + name: "mock-workspace", + index: 0, + }, + ], + createFileSystemWatcher: vi.fn(() => ({ + onDidCreate: vi.fn(() => mockDisposable), + onDidDelete: vi.fn(() => mockDisposable), + onDidChange: vi.fn(() => mockDisposable), + dispose: vi.fn(), + })), + fs: { + stat: vi.fn().mockResolvedValue({ type: 1 }), + }, + onDidSaveTextDocument: vi.fn(() => mockDisposable), + getConfiguration: vi.fn(() => ({ get: (key: string, defaultValue: any) => defaultValue })), + }, + env: { + uriScheme: "vscode", + language: "en", + }, + EventEmitter: vi.fn().mockImplementation(function () { + return mockEventEmitter + }), + Disposable: { + from: vi.fn(), + }, + TabInputText: vi.fn(), + } +}) + +vi.mock("../../mentions", () => ({ + parseMentions: vi.fn().mockImplementation((text) => { + return Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] }) + }), + openMention: vi.fn(), + getLatestTerminalOutput: vi.fn(), +})) + +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), +})) + +vi.mock("../../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: vi.fn().mockResolvedValue(""), +})) + +vi.mock("../../ignore/RooIgnoreController") + +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)), + getSettingsDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)), +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockImplementation(() => false), +})) + +// ── Test Helpers ───────────────────────────────────────────────────────────── + +function makeMockProvider(mockExtensionContext: vscode.ExtensionContext, mockOutputChannel: any) { + const provider = new ClineProvider( + mockExtensionContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockExtensionContext), + ) as any + + provider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) + provider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + provider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) + provider.getState = vi.fn().mockResolvedValue({}) + return provider +} + +function makeMockExtensionContext(): vscode.ExtensionContext { + return { + globalState: { + get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined), + update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + keys: vi.fn().mockReturnValue([]), + }, + globalStorageUri: { + fsPath: path.join(os.tmpdir(), "test-storage-usage-stats"), + }, + workspaceState: { + get: vi.fn().mockImplementation((_key) => undefined), + update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn().mockImplementation((_key) => Promise.resolve(undefined)), + store: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + delete: vi.fn().mockImplementation((_key) => Promise.resolve()), + }, + extensionUri: { + fsPath: "/mock/extension/path", + }, + extension: { + packageJSON: { + version: "1.0.0", + }, + }, + } as unknown as vscode.ExtensionContext +} + +function makeMockApiConfig(): ProviderSettings { + return { + apiProvider: "anthropic", + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-api-key", + } +} + +function makeMockOutputChannel() { + return { + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + } +} + +function makeRecordingContext(overrides?: Partial): UsageRecordingContext { + return { + taskId: "test-task-001", + provider: "anthropic", + model: "claude-3-5-sonnet-20241022", + mode: "code", + attempt: 0, + inputTokens: 100, + outputTokens: 200, + cacheWriteTokens: 10, + cacheReadTokens: 5, + totalCost: 0.001, + cacheReadInInput: "unknown", + cacheWriteInInput: "unknown", + reasoningInOutput: "unknown", + costSource: "provider", + tokenSource: "provider", + ...overrides, + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("Usage Stats Recording", () => { + let mockProvider: any + let mockApiConfig: ProviderSettings + let mockOutputChannel: any + let mockExtensionContext: vscode.ExtensionContext + + beforeEach(() => { + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + mockExtensionContext = makeMockExtensionContext() + mockOutputChannel = makeMockOutputChannel() + mockProvider = makeMockProvider(mockExtensionContext, mockOutputChannel) + mockApiConfig = makeMockApiConfig() + }) + + // ── UsageRecorder Unit Tests ────────────────────────────────────────────── + + describe("UsageRecorder", () => { + it("should initialize usageRecorder on Task construction", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // usageRecorder should be initialized (not null) + // We access it via the private property for testing + expect((task as any).usageRecorder).toBeDefined() + expect((task as any).usageRecorder).not.toBeNull() + expect((task as any).usageRecorder).toBeInstanceOf(UsageRecorder) + }) + + it("should record exactly one event per terminal finalize call", async () => { + const mockStore = { + append: vi.fn().mockResolvedValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + } as unknown as UsageEventSink + const recorder = new UsageRecorder(mockStore) + + const ctx = makeRecordingContext() + await recorder.finalizeUsageEvent("task-1:0", "completed", ctx) + + expect(mockStore.append).toHaveBeenCalledTimes(1) + const recordedEvent = (mockStore.append as any).mock.calls[0][0] + expect(recordedEvent.schemaVersion).toBe(1) + expect(recordedEvent.status).toBe("completed") + expect(recordedEvent.taskId).toBe("test-task-001") + expect(recordedEvent.provider).toBe("anthropic") + expect(recordedEvent.usage.inputTokens.value).toBe(100) + expect(recordedEvent.usage.outputTokens.value).toBe(200) + expect(recordedEvent.usage.costUsd.value).toBe(0.001) + expect(recordedEvent.provenance).toBe("live") + }) + + it("should not record duplicate events for same requestKey + status (idempotency)", async () => { + const mockStore = { + append: vi.fn().mockResolvedValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + } as unknown as UsageEventSink + const recorder = new UsageRecorder(mockStore) + + const ctx = makeRecordingContext() + const requestKey = "task-1:0" + + // First call should record + await recorder.finalizeUsageEvent(requestKey, "completed", ctx) + expect(mockStore.append).toHaveBeenCalledTimes(1) + + // Second call with same key + status should be deduplicated + await recorder.finalizeUsageEvent(requestKey, "completed", ctx) + expect(mockStore.append).toHaveBeenCalledTimes(1) + + // Different status for same requestKey should record (failed vs completed) + await recorder.finalizeUsageEvent(requestKey, "failed", ctx) + expect(mockStore.append).toHaveBeenCalledTimes(2) + }) + + it("should record separate events for different attempts", async () => { + const mockStore = { + append: vi.fn().mockResolvedValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + } as unknown as UsageEventSink + const recorder = new UsageRecorder(mockStore) + + const ctx0 = makeRecordingContext({ attempt: 0 }) + const ctx1 = makeRecordingContext({ attempt: 1, inputTokens: 150 }) + + await recorder.finalizeUsageEvent("task-1:0", "completed", ctx0) + await recorder.finalizeUsageEvent("task-1:1", "completed", ctx1) + + expect(mockStore.append).toHaveBeenCalledTimes(2) + const event0 = (mockStore.append as any).mock.calls[0][0] + const event1 = (mockStore.append as any).mock.calls[1][0] + expect(event0.attempt).toBe(0) + expect(event1.attempt).toBe(1) + expect(event1.usage.inputTokens.value).toBe(150) + }) + + it("should not throw when store.append fails (error isolation)", async () => { + const mockStore = { + append: vi.fn().mockRejectedValue(new Error("disk full")), + initialize: vi.fn().mockResolvedValue(undefined), + } as unknown as UsageEventSink + const recorder = new UsageRecorder(mockStore) + + const ctx = makeRecordingContext() + + // Should not throw + await expect(recorder.finalizeUsageEvent("task-1:0", "completed", ctx)).resolves.toBeUndefined() + expect(mockStore.append).toHaveBeenCalledTimes(1) + }) + + it("should omit token fields with zero values", async () => { + const mockStore = { + append: vi.fn().mockResolvedValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + } as unknown as UsageEventSink + const recorder = new UsageRecorder(mockStore) + + const ctx = makeRecordingContext({ + inputTokens: 0, + outputTokens: 0, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalCost: undefined, + }) + + await recorder.finalizeUsageEvent("task-1:0", "completed", ctx) + + const recordedEvent = (mockStore.append as any).mock.calls[0][0] + expect(recordedEvent.usage.inputTokens).toBeUndefined() + expect(recordedEvent.usage.outputTokens).toBeUndefined() + expect(recordedEvent.usage.cacheWriteTokens).toBeUndefined() + expect(recordedEvent.usage.cacheReadTokens).toBeUndefined() + expect(recordedEvent.usage.costUsd).toBeUndefined() + }) + + it("should include parentTaskId when provided", async () => { + const mockStore = { + append: vi.fn().mockResolvedValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + } as unknown as UsageEventSink + const recorder = new UsageRecorder(mockStore) + + const ctx = makeRecordingContext({ parentTaskId: "parent-task-001" }) + await recorder.finalizeUsageEvent("task-1:0", "completed", ctx) + + const recordedEvent = (mockStore.append as any).mock.calls[0][0] + expect(recordedEvent.parentTaskId).toBe("parent-task-001") + }) + + it("should generate unique eventId for each event", async () => { + const mockStore = { + append: vi.fn().mockResolvedValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + } as unknown as UsageEventSink + const recorder = new UsageRecorder(mockStore) + + const ctx = makeRecordingContext() + await recorder.finalizeUsageEvent("task-1:0", "completed", ctx) + await recorder.finalizeUsageEvent("task-2:0", "completed", ctx) + + const event1 = (mockStore.append as any).mock.calls[0][0] + const event2 = (mockStore.append as any).mock.calls[1][0] + expect(event1.eventId).not.toBe(event2.eventId) + }) + + it("should set idempotencyKey as requestKey:status", async () => { + const mockStore = { + append: vi.fn().mockResolvedValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + } as unknown as UsageEventSink + const recorder = new UsageRecorder(mockStore) + + const ctx = makeRecordingContext() + await recorder.finalizeUsageEvent("task-42:3", "cancelled", ctx) + + const recordedEvent = (mockStore.append as any).mock.calls[0][0] + expect(recordedEvent.idempotencyKey).toBe("task-42:3:cancelled") + }) + + it("should set occurredAt as valid ISO 8601 string", async () => { + const mockStore = { + append: vi.fn().mockResolvedValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + } as unknown as UsageEventSink + const recorder = new UsageRecorder(mockStore) + + const ctx = makeRecordingContext() + await recorder.finalizeUsageEvent("task-1:0", "completed", ctx) + + const recordedEvent = (mockStore.append as any).mock.calls[0][0] + const date = new Date(recordedEvent.occurredAt) + expect(date.getTime()).not.toBeNaN() + }) + + it("should set semantics fields from context", async () => { + const mockStore = { + append: vi.fn().mockResolvedValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + } as unknown as UsageEventSink + const recorder = new UsageRecorder(mockStore) + + const ctx = makeRecordingContext({ + cacheReadInInput: "included", + cacheWriteInInput: "excluded", + reasoningInOutput: "unknown", + }) + await recorder.finalizeUsageEvent("task-1:0", "completed", ctx) + + const recordedEvent = (mockStore.append as any).mock.calls[0][0] + expect(recordedEvent.semantics.cacheReadInInput).toBe("included") + expect(recordedEvent.semantics.cacheWriteInInput).toBe("excluded") + expect(recordedEvent.semantics.reasoningInOutput).toBe("unknown") + }) + }) + + // ── Task Integration Tests ──────────────────────────────────────────────── + + describe("Task integration", () => { + it("should construct usageRecorder as non-null when globalStoragePath is valid", () => { + // The Task constructor injects the provider's shared UsageStatsService as the + // UsageRecorder sink. With a valid globalStoragePath and service, the recorder + // should be successfully constructed (store initialization is deferred to first append). + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // usageRecorder should be a UsageRecorder instance (not null) + expect((task as any).usageRecorder).not.toBeNull() + expect((task as any).usageRecorder).toBeInstanceOf(UsageRecorder) + }) + + it("should have usageRecorder accessible as private property", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // The property should exist + expect((task as any).usageRecorder).toBeDefined() + }) + + it("should construct UsageRecorder with the provider's shared append sink", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const recorder = (task as any).usageRecorder + expect(recorder).toBeInstanceOf(UsageRecorder) + // The recorder should be wired to the provider's UsageStatsService append sink. + expect(recorder.sink).toBe(mockProvider.getUsageStatsService()) + }) + }) + + // ── Terminal Finalize Boundary Tests ───────────────────────────────────── + + describe("Terminal finalize boundary", () => { + it("should use taskId:attempt as requestKey format", async () => { + const mockStore = { + append: vi.fn().mockResolvedValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + } as unknown as UsageEventSink + const recorder = new UsageRecorder(mockStore) + + const ctx = makeRecordingContext({ taskId: "abc-123", attempt: 5 }) + await recorder.finalizeUsageEvent("abc-123:5", "completed", ctx) + + const recordedEvent = (mockStore.append as any).mock.calls[0][0] + // idempotencyKey = requestKey:status + expect(recordedEvent.idempotencyKey).toBe("abc-123:5:completed") + expect(recordedEvent.taskId).toBe("abc-123") + expect(recordedEvent.attempt).toBe(5) + }) + + it("should distinguish completed, failed, and cancelled for same request", async () => { + const mockStore = { + append: vi.fn().mockResolvedValue(true), + initialize: vi.fn().mockResolvedValue(undefined), + } as unknown as UsageEventSink + const recorder = new UsageRecorder(mockStore) + + const ctx = makeRecordingContext() + const requestKey = "task-1:0" + + await recorder.finalizeUsageEvent(requestKey, "completed", ctx) + await recorder.finalizeUsageEvent(requestKey, "failed", ctx) + await recorder.finalizeUsageEvent(requestKey, "cancelled", ctx) + + // All three should be recorded (different statuses) + expect(mockStore.append).toHaveBeenCalledTimes(3) + const statuses = (mockStore.append as any).mock.calls.map((c: any) => c[0].status) + expect(statuses).toContain("completed") + expect(statuses).toContain("failed") + expect(statuses).toContain("cancelled") + }) + }) +}) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 3d6a44ef18..96d6d86f1d 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -175,17 +175,28 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { } pushToolResult(result) - } else { - // Command was submitted but shell integration lost track of it — show warning. - await task.say("shell_integration_warning") - - if (error instanceof ShellIntegrationError) { - pushToolResult( - "Command was submitted in the VS Code terminal, but shell integration did not report its output or completion status. Do not run the command again automatically.", - ) - } else { - pushToolResult(`Command failed to execute in terminal due to a shell integration error.`) + } else if (error instanceof ShellIntegrationError) { + // Command WAS submitted but shell integration lost track of output. + // Retry with execa so the user always gets a result. The original + // command may have already executed in the terminal, so the retried + // output could duplicate or differ — but a result is always better UX + // than a dead-end warning. + const status: CommandExecutionStatus = { executionId, status: "fallback" } + provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + + const [rejected, result] = await executeCommandInTerminal(task, { + ...options, + terminalShellIntegrationDisabled: true, + }) + + if (rejected) { + task.didRejectTool = true } + + pushToolResult(result) + } else { + // Completely unexpected error — not a ShellIntegrationError at all. + pushToolResult(`Command failed to execute in terminal due to a shell integration error.`) } } diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index 1725154328..edc545a19e 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -70,6 +70,7 @@ describe("executeCommandTool", () => { ask: vitest.fn().mockResolvedValue(undefined), say: vitest.fn().mockResolvedValue(undefined), sayAndCreateMissingParamError: vitest.fn().mockResolvedValue("Missing parameter error"), + supersedePendingAsk: vitest.fn(), consecutiveMistakeCount: 0, didRejectTool: false, rooIgnoreController: { @@ -277,7 +278,78 @@ describe("executeCommandTool", () => { expect(executeCommandModule.canRetryShellIntegrationError(error)).toBe(false) }) - + + it("retries with execa fallback when ShellIntegrationError has commandSubmitted=true", async () => { + // The TerminalRegistry mock's runCommand invokes onNoShellIntegration with + // commandSubmitted: true on the first call, which causes executeCommandInTerminal + // to throw a ShellIntegrationError. The catch block should retry with execa + // fallback. On the retry, no shell integration error is triggered. + const { TerminalRegistry } = await import("../../../integrations/terminal/TerminalRegistry") + let callCount = 0 + const mockRunCommand = vitest.fn().mockImplementation((_cmd: string, callbacks: any) => { + callCount++ + if (callCount === 1) { + // First call: simulate shell integration error with commandSubmitted: true + callbacks?.onNoShellIntegration?.({ + message: "stream did not start", + commandSubmitted: true, + }) + } + // Both calls: complete so the process promise resolves + callbacks?.onCompleted?.("") + const p = Promise.resolve() + return Object.assign(p, { continue: () => {}, abort: () => {} }) + }) + ;(TerminalRegistry.getOrCreateTerminal as any).mockResolvedValue({ + runCommand: mockRunCommand, + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/workspace"), + }) + + mockToolUse.params.command = "echo test" + mockToolUse.nativeArgs = { command: "echo test" } + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + // Verify: handleError was NOT called (inner catch handled it) + expect(mockHandleError).not.toHaveBeenCalled() + // Verify: no shell_integration_warning was shown (old behavior removed) + expect(mockCline.say).not.toHaveBeenCalledWith("shell_integration_warning") + // Verify: pushToolResult was called (got a result, not a dead-end error) + expect(mockPushToolResult).toHaveBeenCalled() + // Verify: the result is NOT the old dead-end warning message + const resultArg = mockPushToolResult.mock.calls[0]?.[0] as string + expect(resultArg).not.toContain("shell integration did not report its output") + expect(resultArg).not.toContain("Do not run the command again automatically") + }) + + it("shows error for non-ShellIntegrationError exceptions in the catch block", async () => { + // Simulate a generic error (not ShellIntegrationError) by making + // TerminalRegistry.getOrCreateTerminal throw. + const { TerminalRegistry } = await import("../../../integrations/terminal/TerminalRegistry") + ;(TerminalRegistry.getOrCreateTerminal as any).mockRejectedValue(new Error("Unexpected terminal failure")) + + mockToolUse.params.command = "echo test" + mockToolUse.nativeArgs = { command: "echo test" } + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + // Verify: handleError was NOT called — the inner catch handles non-ShellIntegrationError + // by pushing a generic error message, not by calling handleError. + expect(mockHandleError).not.toHaveBeenCalled() + // Verify: the generic error message was pushed + expect(mockPushToolResult).toHaveBeenCalledWith( + "Command failed to execute in terminal due to a shell integration error.", + ) + }) + it("selects the Execa fallback provider for cmd.exe shell integration", () => { vitest.spyOn(Terminal, "isActiveShellCmdExe").mockReturnValue(true) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 47e31d7e2d..da887cb24e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -81,6 +81,7 @@ import { CodeIndexManager } from "../../services/code-index/manager" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" +import { UsageStatsService } from "../../services/stats" import { fileExistsAtPath } from "../../utils/fs" import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" @@ -180,6 +181,7 @@ export class ClineProvider private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class protected mcpHub?: McpHub // Change from private to protected protected skillsManager?: SkillsManager + private usageStatsService?: UsageStatsService private marketplaceManager: MarketplaceManager private mdmService?: MdmService private taskCreationCallback: (task: Task) => void @@ -279,6 +281,21 @@ export class ClineProvider this.log(`Failed to initialize Skills Manager: ${error}`) }) + // Initialize Usage Stats Service for local token usage tracking. + // Initialization failure is non-fatal — the service becomes unavailable + // and stats handlers return "service unavailable" errors gracefully. + try { + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + this.usageStatsService = new UsageStatsService(globalStoragePath) + this.usageStatsService.initialize().catch((error) => { + this.log(`Failed to initialize Usage Stats Service: ${error}`) + this.usageStatsService = undefined + }) + } catch (error) { + this.log(`Failed to create Usage Stats Service: ${error}`) + this.usageStatsService = undefined + } + this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager) // Forward task events to the provider. @@ -2900,6 +2917,14 @@ export class ClineProvider return this.skillsManager } + /** + * Returns the UsageStatsService instance, or undefined if initialization failed. + * The service provides local token usage statistics: query, export, clear. + */ + public getUsageStatsService(): UsageStatsService | undefined { + return this.usageStatsService + } + /** * Check if the current state is compliant with MDM policy * @returns true if compliant or no MDM policy exists, false if MDM policy exists and user is non-compliant diff --git a/src/core/webview/__tests__/usageStatsMessageHandler.spec.ts b/src/core/webview/__tests__/usageStatsMessageHandler.spec.ts new file mode 100644 index 0000000000..54017e6b57 --- /dev/null +++ b/src/core/webview/__tests__/usageStatsMessageHandler.spec.ts @@ -0,0 +1,1239 @@ +import type { WebviewMessage, StatsQuery, StatsSnapshot, UsageEventV1 } from "@roo-code/types" +import type { ClineProvider } from "../ClineProvider" +import type { UsageStatsService, JsonExport } from "../../../services/stats" +import { StatsServiceError } from "../../../services/stats" + +vi.mock("vscode", () => ({ + window: { + showSaveDialog: vi.fn(), + showErrorMessage: vi.fn(), + }, + workspace: { + fs: { + writeFile: vi.fn(), + }, + }, +})) + +vi.mock("../../../utils/export", () => ({ + resolveDefaultSaveUri: vi.fn(), + saveLastExportPath: vi.fn(), +})) + +vi.mock("../../task-persistence/taskMessages", () => ({ + readTaskMessages: vi.fn().mockResolvedValue([]), +})) + +vi.mock("../../../services/stats/costRecalculation", () => ({ + getEffectiveCost: vi.fn((event: UsageEventV1) => event.usage.costUsd?.value ?? 0), +})) + +import * as vscode from "vscode" +import { resolveDefaultSaveUri, saveLastExportPath } from "../../../utils/export" +import { getEffectiveCost } from "../../../services/stats/costRecalculation" +import { + handleGetUsageStats, + handleClearUsageStats, + handleExportUsageStats, + handleRequestClearNonce, + handleGetDashboardSessions, + handleGetDashboardSessionDetail, +} from "../usageStatsMessageHandler" + +// ── Test Fixtures ──────────────────────────────────────────────────────────── + +const validQuery: StatsQuery = { + timezone: "UTC", + groupBy: ["day"], + includeCancelled: false, +} + +const mockSnapshot: StatsSnapshot = { + query: validQuery, + generatedAt: "2026-07-19T00:00:00.000Z", + buckets: [], + totals: { + key: {}, + events: 0, + completedCalls: 0, + failedCalls: 0, + cancelledCalls: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + costUsd: 0, + unknownEventCount: 0, + }, + coverage: { + recordingPaused: false, + backfilledEventCount: 0, + }, +} + +const mockJsonExport: JsonExport = { + exportSchemaVersion: 1, + exportedAt: "2026-07-19T00:00:00.000Z", + query: validQuery, + events: [], +} + +// ── Mock Provider Factory ──────────────────────────────────────────────────── + +const createMockProvider = (service?: Partial): ClineProvider => { + const mockLog = vi.fn() + const mockPostMessageToWebview = vi.fn() + const mockContextProxy = { + getValue: vi.fn(), + setValue: vi.fn(), + globalStorageUri: { fsPath: "/tmp/globalStorage" } as vscode.Uri, + } + + // Default getFilteredEvents falls back to an exportStats mock if provided, + // so legacy tests that only supply exportStats still work. New tests should + // supply getFilteredEvents directly for the optimized path. + const legacyService = service ?? {} + if (!legacyService.getFilteredEvents && legacyService.exportStats) { + legacyService.getFilteredEvents = vi.fn(async (query: StatsQuery) => { + const exportData = await legacyService.exportStats!(query, "json") + return (exportData as JsonExport).events ?? [] + }) + } + + let mockService: UsageStatsService | undefined = legacyService as UsageStatsService | undefined + if (Object.keys(legacyService).length === 0) { + // caller passed undefined explicitly + mockService = undefined + } + + return { + log: mockLog, + postMessageToWebview: mockPostMessageToWebview, + getUsageStatsService: vi.fn(() => mockService), + contextProxy: mockContextProxy, + } as unknown as ClineProvider +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("usageStatsMessageHandler", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(resolveDefaultSaveUri).mockResolvedValue(undefined as unknown as vscode.Uri) + vi.mocked(saveLastExportPath).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.fs.writeFile).mockResolvedValue(undefined) + }) + + // ── handleGetUsageStats ────────────────────────────────────────────────── + + describe("handleGetUsageStats", () => { + it("posts snapshot on valid query", async () => { + const queryStats = vi.fn().mockResolvedValue(mockSnapshot) + const isCapped = vi.fn(() => false) + const provider = createMockProvider({ queryStats, isCapped }) + + const message: WebviewMessage = { + type: "getUsageStats", + requestId: "req-1", + usageStatsQuery: validQuery, + } + + await handleGetUsageStats(provider, message) + + expect(queryStats).toHaveBeenCalledWith(validQuery, { recordingPaused: false }) + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "getUsageStatsResponse", + requestId: "req-1", + usageStatsSnapshot: mockSnapshot, + }) + }) + + it("returns error when service is unavailable", async () => { + const provider = createMockProvider(undefined) + + const message: WebviewMessage = { + type: "getUsageStats", + requestId: "req-2", + usageStatsQuery: validQuery, + } + + await handleGetUsageStats(provider, message) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "getUsageStatsResponse", + requestId: "req-2", + error: expect.stringContaining("STATS_HANDLER/query/002"), + }) + }) + + it("rejects invalid payload (missing timezone)", async () => { + const queryStats = vi.fn() + const provider = createMockProvider({ queryStats }) + + const message: WebviewMessage = { + type: "getUsageStats", + requestId: "req-3", + usageStatsQuery: { + groupBy: ["day"], + } as StatsQuery, // missing timezone + } + + await handleGetUsageStats(provider, message) + + expect(queryStats).not.toHaveBeenCalled() + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "getUsageStatsResponse", + requestId: "req-3", + error: expect.stringContaining("STATS_HANDLER/query/001"), + }) + }) + + it("rejects invalid payload (missing groupBy)", async () => { + const queryStats = vi.fn() + const provider = createMockProvider({ queryStats }) + + const message: WebviewMessage = { + type: "getUsageStats", + requestId: "req-4", + usageStatsQuery: { + timezone: "UTC", + } as StatsQuery, // missing groupBy + } + + await handleGetUsageStats(provider, message) + + expect(queryStats).not.toHaveBeenCalled() + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "getUsageStatsResponse", + requestId: "req-4", + error: expect.stringContaining("STATS_HANDLER/query/001"), + }) + }) + + it("returns error on service exception", async () => { + const queryStats = vi.fn().mockRejectedValue(new Error("store read failed")) + const isCapped = vi.fn(() => false) + const provider = createMockProvider({ queryStats, isCapped }) + + const message: WebviewMessage = { + type: "getUsageStats", + requestId: "req-5", + usageStatsQuery: validQuery, + } + + await handleGetUsageStats(provider, message) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "getUsageStatsResponse", + requestId: "req-5", + error: expect.stringContaining("STATS_HANDLER/query/003"), + }) + }) + + it("passes recordingPaused=true when service is capped", async () => { + const queryStats = vi.fn().mockResolvedValue(mockSnapshot) + const isCapped = vi.fn(() => true) + const provider = createMockProvider({ queryStats, isCapped }) + + const message: WebviewMessage = { + type: "getUsageStats", + requestId: "req-6", + usageStatsQuery: validQuery, + } + + await handleGetUsageStats(provider, message) + + expect(queryStats).toHaveBeenCalledWith(validQuery, { recordingPaused: true }) + }) + }) + + // ── handleClearUsageStats ──────────────────────────────────────────────── + + describe("handleClearUsageStats", () => { + it("clears stats on valid nonce", async () => { + const clearStats = vi.fn().mockResolvedValue(undefined) + const provider = createMockProvider({ clearStats }) + + const message: WebviewMessage = { + type: "clearUsageStats", + requestId: "req-clear-1", + clearUsageStatsNonce: "valid-nonce-123", + } + + await handleClearUsageStats(provider, message) + + expect(clearStats).toHaveBeenCalledWith("valid-nonce-123") + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "usageStatsChanged", + }) + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "clearUsageStatsResponse", + requestId: "req-clear-1", + clearUsageStatsResult: { success: true }, + }) + }) + + it("rejects missing nonce", async () => { + const clearStats = vi.fn() + const provider = createMockProvider({ clearStats }) + + const message: WebviewMessage = { + type: "clearUsageStats", + requestId: "req-clear-2", + clearUsageStatsNonce: undefined, + } + + await handleClearUsageStats(provider, message) + + expect(clearStats).not.toHaveBeenCalled() + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "clearUsageStatsResponse", + requestId: "req-clear-2", + clearUsageStatsResult: { + success: false, + error: expect.stringContaining("STATS_HANDLER/clear/001"), + }, + }) + }) + + it("rejects empty nonce", async () => { + const clearStats = vi.fn() + const provider = createMockProvider({ clearStats }) + + const message: WebviewMessage = { + type: "clearUsageStats", + requestId: "req-clear-3", + clearUsageStatsNonce: "", + } + + await handleClearUsageStats(provider, message) + + expect(clearStats).not.toHaveBeenCalled() + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "clearUsageStatsResponse", + requestId: "req-clear-3", + clearUsageStatsResult: { + success: false, + error: expect.stringContaining("STATS_HANDLER/clear/001"), + }, + }) + }) + + it("returns error when service is unavailable", async () => { + const provider = createMockProvider(undefined) + + const message: WebviewMessage = { + type: "clearUsageStats", + requestId: "req-clear-4", + clearUsageStatsNonce: "some-nonce", + } + + await handleClearUsageStats(provider, message) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "clearUsageStatsResponse", + requestId: "req-clear-4", + clearUsageStatsResult: { + success: false, + error: expect.stringContaining("STATS_HANDLER/clear/002"), + }, + }) + }) + + it("returns error on expired nonce (StatsServiceError)", async () => { + const clearStats = vi.fn().mockRejectedValue( + new StatsServiceError("STATS_SERVICE/clear/001", "nonce expired"), + ) + const provider = createMockProvider({ clearStats }) + + const message: WebviewMessage = { + type: "clearUsageStats", + requestId: "req-clear-5", + clearUsageStatsNonce: "expired-nonce", + } + + await handleClearUsageStats(provider, message) + + expect(clearStats).toHaveBeenCalledWith("expired-nonce") + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "clearUsageStatsResponse", + requestId: "req-clear-5", + clearUsageStatsResult: { + success: false, + error: expect.stringContaining("STATS_HANDLER/clear/003"), + }, + }) + }) + }) + + // ── handleExportUsageStats ─────────────────────────────────────────────── + + describe("handleExportUsageStats", () => { + it("exports JSON and writes file", async () => { + const exportStats = vi.fn().mockResolvedValue(mockJsonExport) + const isCapped = vi.fn(() => false) + const provider = createMockProvider({ exportStats, isCapped }) + + const mockUri = { fsPath: "/tmp/usage-stats.json" } as vscode.Uri + vi.mocked(vscode.window.showSaveDialog).mockResolvedValue(mockUri) + + const message: WebviewMessage = { + type: "exportUsageStats", + requestId: "req-export-1", + exportUsageStatsFormat: "json", + usageStatsQuery: validQuery, + } + + await handleExportUsageStats(provider, message) + + expect(exportStats).toHaveBeenCalledWith(validQuery, "json") + expect(vscode.workspace.fs.writeFile).toHaveBeenCalled() + expect(saveLastExportPath).toHaveBeenCalled() + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "exportUsageStatsResponse", + requestId: "req-export-1", + exportUsageStatsResult: { + format: "json", + data: "usage-stats.json", + }, + }) + }) + + it("exports CSV and writes file", async () => { + const csvContent = "eventId,status\nevt-1,completed" + const exportStats = vi.fn().mockResolvedValue(csvContent) + const isCapped = vi.fn(() => false) + const provider = createMockProvider({ exportStats, isCapped }) + + const mockUri = { fsPath: "/tmp/usage-stats.csv" } as vscode.Uri + vi.mocked(vscode.window.showSaveDialog).mockResolvedValue(mockUri) + + const message: WebviewMessage = { + type: "exportUsageStats", + requestId: "req-export-2", + exportUsageStatsFormat: "csv", + usageStatsQuery: validQuery, + } + + await handleExportUsageStats(provider, message) + + expect(exportStats).toHaveBeenCalledWith(validQuery, "csv") + expect(vscode.workspace.fs.writeFile).toHaveBeenCalled() + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "exportUsageStatsResponse", + requestId: "req-export-2", + exportUsageStatsResult: { + format: "csv", + data: "usage-stats.csv", + }, + }) + }) + + it("handles save dialog cancel (not an error)", async () => { + const exportStats = vi.fn().mockResolvedValue(mockJsonExport) + const isCapped = vi.fn(() => false) + const provider = createMockProvider({ exportStats, isCapped }) + + vi.mocked(vscode.window.showSaveDialog).mockResolvedValue(undefined) + + const message: WebviewMessage = { + type: "exportUsageStats", + requestId: "req-export-3", + exportUsageStatsFormat: "json", + usageStatsQuery: validQuery, + } + + await handleExportUsageStats(provider, message) + + expect(exportStats).toHaveBeenCalledWith(validQuery, "json") + expect(vscode.workspace.fs.writeFile).not.toHaveBeenCalled() + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "exportUsageStatsResponse", + requestId: "req-export-3", + exportUsageStatsResult: { + format: "json", + data: "", + }, + }) + }) + + it("rejects unsupported format", async () => { + const exportStats = vi.fn() + const provider = createMockProvider({ exportStats }) + + const message: WebviewMessage = { + type: "exportUsageStats", + requestId: "req-export-4", + exportUsageStatsFormat: "xml" as "json", + usageStatsQuery: validQuery, + } + + await handleExportUsageStats(provider, message) + + expect(exportStats).not.toHaveBeenCalled() + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "exportUsageStatsResponse", + requestId: "req-export-4", + exportUsageStatsResult: { + format: "json", + data: "", + error: expect.stringContaining("STATS_HANDLER/export/004"), + }, + }) + }) + + it("rejects invalid query", async () => { + const exportStats = vi.fn() + const provider = createMockProvider({ exportStats }) + + const message: WebviewMessage = { + type: "exportUsageStats", + requestId: "req-export-5", + exportUsageStatsFormat: "json", + usageStatsQuery: { + groupBy: ["day"], + } as StatsQuery, // missing timezone + } + + await handleExportUsageStats(provider, message) + + expect(exportStats).not.toHaveBeenCalled() + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "exportUsageStatsResponse", + requestId: "req-export-5", + exportUsageStatsResult: { + format: "json", + data: "", + error: expect.stringContaining("STATS_HANDLER/export/001"), + }, + }) + }) + + it("returns error when service is unavailable", async () => { + const provider = createMockProvider(undefined) + + const message: WebviewMessage = { + type: "exportUsageStats", + requestId: "req-export-6", + exportUsageStatsFormat: "json", + usageStatsQuery: validQuery, + } + + await handleExportUsageStats(provider, message) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "exportUsageStatsResponse", + requestId: "req-export-6", + exportUsageStatsResult: { + format: "json", + data: "", + error: expect.stringContaining("STATS_HANDLER/export/002"), + }, + }) + }) + + it("returns error on service exception", async () => { + const exportStats = vi.fn().mockRejectedValue(new Error("store read failed")) + const isCapped = vi.fn(() => false) + const provider = createMockProvider({ exportStats, isCapped }) + + const message: WebviewMessage = { + type: "exportUsageStats", + requestId: "req-export-7", + exportUsageStatsFormat: "json", + usageStatsQuery: validQuery, + } + + await handleExportUsageStats(provider, message) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "exportUsageStatsResponse", + requestId: "req-export-7", + exportUsageStatsResult: { + format: "json", + data: "", + error: expect.stringContaining("STATS_HANDLER/export/003"), + }, + }) + }) + }) + + // ── handleRequestClearNonce ────────────────────────────────────────────── + + describe("handleRequestClearNonce", () => { + it("posts requestClearNonceResponse with nonce from service", async () => { + const issueClearNonce = vi.fn(() => "test-nonce-abc") + const provider = createMockProvider({ issueClearNonce }) + + const message: WebviewMessage = { + type: "requestClearNonce", + requestId: "req-nonce-1", + } + + await handleRequestClearNonce(provider, message) + + expect(issueClearNonce).toHaveBeenCalled() + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "requestClearNonceResponse", + requestId: "req-nonce-1", + clearNonce: "test-nonce-abc", + }) + }) + + it("posts error response when service is unavailable", async () => { + const provider = createMockProvider(undefined) + + const message: WebviewMessage = { + type: "requestClearNonce", + requestId: "req-nonce-2", + } + + await handleRequestClearNonce(provider, message) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "requestClearNonceResponse", + requestId: "req-nonce-2", + clearNonce: null, + error: expect.stringContaining("[STATS_HANDLER/clear/002]"), + }) + }) + }) + + // ── handleGetDashboardSessions ──────────────────────────────────────────── + + describe("handleGetDashboardSessions", () => { + const makeEvent = (overrides: Partial = {}): UsageEventV1 => ({ + schemaVersion: 1, + eventId: `evt-${Math.random().toString(36).slice(2)}`, + idempotencyKey: `key-${Math.random().toString(36).slice(2)}`, + occurredAt: "2026-07-19T10:00:00.000Z", + timezoneOffsetMinutes: 0, + status: "completed", + attempt: 1, + taskId: "task-001", + provider: "openai", + model: "gpt-4", + mode: "code", + usage: { + inputTokens: { value: 100, source: "provider" }, + outputTokens: { value: 50, source: "provider" }, + totalTokens: { value: 150, source: "provider" }, + costUsd: { value: 0.05, source: "provider" }, + }, + semantics: { + cacheReadInInput: "excluded", + cacheWriteInInput: "excluded", + reasoningInOutput: "excluded", + }, + provenance: "live", + ...overrides, + }) + + it("returns empty sessions list when no events", async () => { + const getFilteredEvents = vi.fn().mockResolvedValue(mockJsonExport.events) + const provider = createMockProvider({ getFilteredEvents }) + + const message: WebviewMessage = { + type: "getDashboardSessions", + requestId: "req-sessions-1", + usageStatsQuery: validQuery, + } + + await handleGetDashboardSessions(provider, message) + + expect(getFilteredEvents).toHaveBeenCalledWith(validQuery) + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "dashboardSessionsResponse", + requestId: "req-sessions-1", + dashboardSessions: [], + }) + }) + + it("uses getFilteredEvents directly instead of exportStats", async () => { + const getFilteredEvents = vi.fn().mockResolvedValue([]) + const exportStats = vi.fn() + const provider = createMockProvider({ getFilteredEvents, exportStats }) + + const message: WebviewMessage = { + type: "getDashboardSessions", + requestId: "req-sessions-1b", + usageStatsQuery: validQuery, + } + + await handleGetDashboardSessions(provider, message) + + expect(getFilteredEvents).toHaveBeenCalledWith(validQuery) + expect(exportStats).not.toHaveBeenCalled() + }) + + it("groups events by root taskId and returns summaries", async () => { + const events: UsageEventV1[] = [ + makeEvent({ + taskId: "task-A", + occurredAt: "2026-07-19T10:00:00.000Z", + model: "gpt-4", + mode: "code", + provider: "openai", + usage: { + inputTokens: { value: 100, source: "provider" }, + outputTokens: { value: 50, source: "provider" }, + totalTokens: { value: 150, source: "provider" }, + costUsd: { value: 0.05, source: "provider" }, + }, + }), + makeEvent({ + taskId: "task-B", + occurredAt: "2026-07-19T11:00:00.000Z", + model: "claude-3", + mode: "architect", + provider: "anthropic", + usage: { + inputTokens: { value: 200, source: "provider" }, + outputTokens: { value: 100, source: "provider" }, + totalTokens: { value: 300, source: "provider" }, + costUsd: { value: 0.10, source: "provider" }, + }, + }), + ] + + const getFilteredEvents = vi.fn().mockResolvedValue(events) + const provider = createMockProvider({ getFilteredEvents }) + + const message: WebviewMessage = { + type: "getDashboardSessions", + requestId: "req-sessions-2", + usageStatsQuery: validQuery, + } + + await handleGetDashboardSessions(provider, message) + + const response = vi.mocked(provider.postMessageToWebview).mock.calls.find( + (c) => c[0]?.type === "dashboardSessionsResponse", + ) + + expect(response).toBeDefined() + expect(response?.[0].dashboardSessions).toHaveLength(2) + + // Sessions should be sorted by timestamp descending (task-B is later) + const sessions = response?.[0].dashboardSessions + expect(sessions?.[0].taskId).toBe("task-B") + expect(sessions?.[1].taskId).toBe("task-A") + + // Verify summary fields + expect(sessions?.[0]).toMatchObject({ + taskId: "task-B", + model: "claude-3", + provider: "anthropic", + mode: "architect", + models: ["claude-3"], + modes: ["architect"], + totalTokens: 300, + totalCost: 0.10, + callCount: 1, + }) + }) + + it("groups subtask events under root task via parentTaskId", async () => { + const events: UsageEventV1[] = [ + makeEvent({ + taskId: "task-root", + occurredAt: "2026-07-19T10:00:00.000Z", + parentTaskId: undefined, + }), + makeEvent({ + taskId: "task-sub-1", + occurredAt: "2026-07-19T10:30:00.000Z", + parentTaskId: "task-root", + }), + makeEvent({ + taskId: "task-sub-2", + occurredAt: "2026-07-19T11:00:00.000Z", + parentTaskId: "task-root", + }), + ] + + const exportData: JsonExport = { ...mockJsonExport, events } + const exportStats = vi.fn().mockResolvedValue(exportData) + const provider = createMockProvider({ exportStats }) + + const message: WebviewMessage = { + type: "getDashboardSessions", + requestId: "req-sessions-3", + usageStatsQuery: validQuery, + } + + await handleGetDashboardSessions(provider, message) + + const response = vi.mocked(provider.postMessageToWebview).mock.calls.find( + (c) => c[0]?.type === "dashboardSessionsResponse", + ) + + expect(response?.[0].dashboardSessions).toHaveLength(1) + expect(response?.[0].dashboardSessions?.[0].taskId).toBe("task-root") + expect(response?.[0].dashboardSessions?.[0].callCount).toBe(3) + }) + + it("applies model filter post-grouping", async () => { + const events: UsageEventV1[] = [ + makeEvent({ + taskId: "task-A", + model: "gpt-4", + occurredAt: "2026-07-19T10:00:00.000Z", + }), + makeEvent({ + taskId: "task-B", + model: "claude-3", + occurredAt: "2026-07-19T11:00:00.000Z", + }), + ] + + const exportData: JsonExport = { ...mockJsonExport, events } + const exportStats = vi.fn().mockResolvedValue(exportData) + const provider = createMockProvider({ exportStats }) + + const message: WebviewMessage = { + type: "getDashboardSessions", + requestId: "req-sessions-4", + usageStatsQuery: validQuery, + dashboardSessionFilters: { model: "gpt-4" }, + } + + await handleGetDashboardSessions(provider, message) + + const response = vi.mocked(provider.postMessageToWebview).mock.calls.find( + (c) => c[0]?.type === "dashboardSessionsResponse", + ) + + expect(response?.[0].dashboardSessions).toHaveLength(1) + expect(response?.[0].dashboardSessions?.[0].taskId).toBe("task-A") + }) + + it("applies provider filter post-grouping", async () => { + const events: UsageEventV1[] = [ + makeEvent({ + taskId: "task-A", + provider: "openai", + occurredAt: "2026-07-19T10:00:00.000Z", + }), + makeEvent({ + taskId: "task-B", + provider: "anthropic", + occurredAt: "2026-07-19T11:00:00.000Z", + }), + ] + + const exportData: JsonExport = { ...mockJsonExport, events } + const exportStats = vi.fn().mockResolvedValue(exportData) + const provider = createMockProvider({ exportStats }) + + const message: WebviewMessage = { + type: "getDashboardSessions", + requestId: "req-sessions-5", + usageStatsQuery: validQuery, + dashboardSessionFilters: { provider: "anthropic" }, + } + + await handleGetDashboardSessions(provider, message) + + const response = vi.mocked(provider.postMessageToWebview).mock.calls.find( + (c) => c[0]?.type === "dashboardSessionsResponse", + ) + + expect(response?.[0].dashboardSessions).toHaveLength(1) + expect(response?.[0].dashboardSessions?.[0].taskId).toBe("task-B") + }) + + it("returns error when service is unavailable", async () => { + const provider = createMockProvider(undefined) + + const message: WebviewMessage = { + type: "getDashboardSessions", + requestId: "req-sessions-6", + usageStatsQuery: validQuery, + } + + await handleGetDashboardSessions(provider, message) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "dashboardSessionsResponse", + requestId: "req-sessions-6", + dashboardSessions: null, + error: expect.stringContaining("STATS_HANDLER/sessions/002"), + }) + }) + + it("rejects invalid query", async () => { + const exportStats = vi.fn() + const provider = createMockProvider({ exportStats }) + + const message: WebviewMessage = { + type: "getDashboardSessions", + requestId: "req-sessions-7", + usageStatsQuery: { + groupBy: ["day"], + } as StatsQuery, // missing timezone + } + + await handleGetDashboardSessions(provider, message) + + expect(exportStats).not.toHaveBeenCalled() + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "dashboardSessionsResponse", + requestId: "req-sessions-7", + dashboardSessions: null, + error: expect.stringContaining("STATS_HANDLER/sessions/001"), + }) + }) + + it("returns error on service exception", async () => { + const exportStats = vi.fn().mockRejectedValue(new Error("store read failed")) + const provider = createMockProvider({ exportStats }) + + const message: WebviewMessage = { + type: "getDashboardSessions", + requestId: "req-sessions-8", + usageStatsQuery: validQuery, + } + + await handleGetDashboardSessions(provider, message) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "dashboardSessionsResponse", + requestId: "req-sessions-8", + dashboardSessions: null, + error: expect.stringContaining("STATS_HANDLER/sessions/003"), + }) + }) + + it("uses getEffectiveCost for events without costUsd", async () => { + vi.mocked(getEffectiveCost).mockReturnValue(0.15) + + const events: UsageEventV1[] = [ + makeEvent({ + taskId: "task-A", + usage: { + inputTokens: { value: 100, source: "provider" }, + outputTokens: { value: 50, source: "provider" }, + totalTokens: { value: 150, source: "provider" }, + // costUsd intentionally missing + }, + }), + ] + + const exportData: JsonExport = { ...mockJsonExport, events } + const exportStats = vi.fn().mockResolvedValue(exportData) + const provider = createMockProvider({ exportStats }) + + const message: WebviewMessage = { + type: "getDashboardSessions", + requestId: "req-sessions-9", + usageStatsQuery: validQuery, + } + + await handleGetDashboardSessions(provider, message) + + expect(getEffectiveCost).toHaveBeenCalled() + const response = vi.mocked(provider.postMessageToWebview).mock.calls.find( + (c) => c[0]?.type === "dashboardSessionsResponse", + ) + expect(response?.[0].dashboardSessions?.[0].totalCost).toBe(0.15) + + // Reset mock to default + vi.mocked(getEffectiveCost).mockImplementation( + (event: UsageEventV1) => event.usage.costUsd?.value ?? 0, + ) + }) + }) + + // ── handleGetDashboardSessionDetail ─────────────────────────────────────── + + describe("handleGetDashboardSessionDetail", () => { + const makeEvent = (overrides: Partial = {}): UsageEventV1 => ({ + schemaVersion: 1, + eventId: `evt-${Math.random().toString(36).slice(2)}`, + idempotencyKey: `key-${Math.random().toString(36).slice(2)}`, + occurredAt: "2026-07-19T10:00:00.000Z", + timezoneOffsetMinutes: 0, + status: "completed", + attempt: 1, + taskId: "task-001", + provider: "openai", + model: "gpt-4", + mode: "code", + usage: { + inputTokens: { value: 100, source: "provider" }, + outputTokens: { value: 50, source: "provider" }, + totalTokens: { value: 150, source: "provider" }, + costUsd: { value: 0.05, source: "provider" }, + }, + semantics: { + cacheReadInInput: "excluded", + cacheWriteInInput: "excluded", + reasoningInOutput: "excluded", + }, + provenance: "live", + ...overrides, + }) + + it("returns session detail with apiCalls for a valid taskId", async () => { + const events: UsageEventV1[] = [ + makeEvent({ + taskId: "task-001", + occurredAt: "2026-07-19T10:00:00.000Z", + usage: { + inputTokens: { value: 100, source: "provider" }, + outputTokens: { value: 50, source: "provider" }, + totalTokens: { value: 150, source: "provider" }, + costUsd: { value: 0.05, source: "provider" }, + }, + }), + makeEvent({ + taskId: "task-001", + occurredAt: "2026-07-19T10:30:00.000Z", + usage: { + inputTokens: { value: 200, source: "provider" }, + outputTokens: { value: 100, source: "provider" }, + totalTokens: { value: 300, source: "provider" }, + costUsd: { value: 0.10, source: "provider" }, + }, + }), + ] + + const exportData: JsonExport = { ...mockJsonExport, events } + const exportStats = vi.fn().mockResolvedValue(exportData) + const provider = createMockProvider({ exportStats }) + + const message: WebviewMessage = { + type: "getDashboardSessionDetail", + requestId: "req-detail-1", + taskId: "task-001", + } + + await handleGetDashboardSessionDetail(provider, message) + + const response = vi.mocked(provider.postMessageToWebview).mock.calls.find( + (c) => c[0]?.type === "dashboardSessionDetailResponse", + ) + + expect(response).toBeDefined() + const detail = response?.[0].dashboardSessionDetail + expect(detail).not.toBeNull() + expect(detail?.taskId).toBe("task-001") + expect(detail?.callCount).toBe(2) + expect(detail?.totalTokens).toBe(450) + expect(detail?.totalCost).toBeCloseTo(0.15, 10) + expect(detail?.apiCalls).toHaveLength(2) + expect(detail?.apiCalls?.[0]).toMatchObject({ + index: 1, + mode: "code", + inputTokens: 100, + outputTokens: 50, + costUsd: 0.05, + status: "completed", + model: "gpt-4", + }) + expect(detail?.apiCalls?.[1]).toMatchObject({ + index: 2, + inputTokens: 200, + outputTokens: 100, + costUsd: 0.10, + }) + }) + + it("includes subtask events via parentTaskId chain", async () => { + const events: UsageEventV1[] = [ + makeEvent({ + taskId: "task-root", + parentTaskId: undefined, + occurredAt: "2026-07-19T10:00:00.000Z", + }), + makeEvent({ + taskId: "task-sub-1", + parentTaskId: "task-root", + occurredAt: "2026-07-19T10:30:00.000Z", + }), + makeEvent({ + taskId: "task-other", + occurredAt: "2026-07-19T11:00:00.000Z", + }), + ] + + const exportData: JsonExport = { ...mockJsonExport, events } + const exportStats = vi.fn().mockResolvedValue(exportData) + const provider = createMockProvider({ exportStats }) + + const message: WebviewMessage = { + type: "getDashboardSessionDetail", + requestId: "req-detail-2", + taskId: "task-root", + } + + await handleGetDashboardSessionDetail(provider, message) + + const response = vi.mocked(provider.postMessageToWebview).mock.calls.find( + (c) => c[0]?.type === "dashboardSessionDetailResponse", + ) + + expect(response?.[0].dashboardSessionDetail?.callCount).toBe(2) + expect(response?.[0].dashboardSessionDetail?.apiCalls).toHaveLength(2) + }) + + it("returns empty detail when no events match taskId", async () => { + const exportData: JsonExport = { ...mockJsonExport, events: [] } + const exportStats = vi.fn().mockResolvedValue(exportData) + const provider = createMockProvider({ exportStats }) + + const message: WebviewMessage = { + type: "getDashboardSessionDetail", + requestId: "req-detail-3", + taskId: "nonexistent-task", + } + + await handleGetDashboardSessionDetail(provider, message) + + const response = vi.mocked(provider.postMessageToWebview).mock.calls.find( + (c) => c[0]?.type === "dashboardSessionDetailResponse", + ) + + expect(response?.[0].dashboardSessionDetail).toMatchObject({ + taskId: "nonexistent-task", + timestamp: 0, + model: "", + provider: "", + mode: "", + models: [], + modes: [], + totalTokens: 0, + totalCost: 0, + callCount: 0, + apiCalls: [], + }) + }) + + it("accepts taskId via message.text field", async () => { + const events: UsageEventV1[] = [ + makeEvent({ taskId: "task-from-text" }), + ] + const exportData: JsonExport = { ...mockJsonExport, events } + const exportStats = vi.fn().mockResolvedValue(exportData) + const provider = createMockProvider({ exportStats }) + + const message: WebviewMessage = { + type: "getDashboardSessionDetail", + requestId: "req-detail-4", + text: "task-from-text", + } + + await handleGetDashboardSessionDetail(provider, message) + + const response = vi.mocked(provider.postMessageToWebview).mock.calls.find( + (c) => c[0]?.type === "dashboardSessionDetailResponse", + ) + + expect(response?.[0].dashboardSessionDetail?.taskId).toBe("task-from-text") + }) + + it("returns error when taskId is missing", async () => { + const exportStats = vi.fn() + const provider = createMockProvider({ exportStats }) + + const message: WebviewMessage = { + type: "getDashboardSessionDetail", + requestId: "req-detail-5", + // No taskId or text + } + + await handleGetDashboardSessionDetail(provider, message) + + expect(exportStats).not.toHaveBeenCalled() + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "dashboardSessionDetailResponse", + requestId: "req-detail-5", + dashboardSessionDetail: null, + error: expect.stringContaining("STATS_HANDLER/sessionDetail/001"), + }) + }) + + it("returns error when service is unavailable", async () => { + const provider = createMockProvider(undefined) + + const message: WebviewMessage = { + type: "getDashboardSessionDetail", + requestId: "req-detail-6", + taskId: "task-001", + } + + await handleGetDashboardSessionDetail(provider, message) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "dashboardSessionDetailResponse", + requestId: "req-detail-6", + dashboardSessionDetail: null, + error: expect.stringContaining("STATS_HANDLER/sessionDetail/002"), + }) + }) + + it("returns error on service exception", async () => { + const exportStats = vi.fn().mockRejectedValue(new Error("store read failed")) + const provider = createMockProvider({ exportStats }) + + const message: WebviewMessage = { + type: "getDashboardSessionDetail", + requestId: "req-detail-7", + taskId: "task-001", + } + + await handleGetDashboardSessionDetail(provider, message) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "dashboardSessionDetailResponse", + requestId: "req-detail-7", + dashboardSessionDetail: null, + error: expect.stringContaining("STATS_HANDLER/sessionDetail/003"), + }) + }) + + it("maps events with failed/cancelled status to apiCalls", async () => { + const events: UsageEventV1[] = [ + makeEvent({ + taskId: "task-001", + status: "completed", + occurredAt: "2026-07-19T10:00:00.000Z", + }), + makeEvent({ + taskId: "task-001", + status: "failed", + occurredAt: "2026-07-19T10:30:00.000Z", + }), + makeEvent({ + taskId: "task-001", + status: "cancelled", + occurredAt: "2026-07-19T11:00:00.000Z", + }), + ] + + const exportData: JsonExport = { ...mockJsonExport, events } + const exportStats = vi.fn().mockResolvedValue(exportData) + const provider = createMockProvider({ exportStats }) + + const message: WebviewMessage = { + type: "getDashboardSessionDetail", + requestId: "req-detail-8", + taskId: "task-001", + } + + await handleGetDashboardSessionDetail(provider, message) + + const response = vi.mocked(provider.postMessageToWebview).mock.calls.find( + (c) => c[0]?.type === "dashboardSessionDetailResponse", + ) + + const apiCalls = response?.[0].dashboardSessionDetail?.apiCalls + expect(apiCalls?.[0].status).toBe("completed") + expect(apiCalls?.[1].status).toBe("failed") + expect(apiCalls?.[2].status).toBe("cancelled") + }) + }) +}) diff --git a/src/core/webview/usageStatsMessageHandler.ts b/src/core/webview/usageStatsMessageHandler.ts new file mode 100644 index 0000000000..4677ebb196 --- /dev/null +++ b/src/core/webview/usageStatsMessageHandler.ts @@ -0,0 +1,898 @@ +import * as vscode from "vscode" +import * as path from "path" +import * as os from "os" + +import type { + WebviewMessage, + StatsQuery, + StatsSnapshot, + SessionSummary, + SessionDetail, + APICallRecord, + UsageEventV1, +} from "@roo-code/types" +import { StatsQuery as StatsQuerySchema } from "@roo-code/types" + +import type { ClineProvider } from "./ClineProvider" +import type { UsageStatsService, JsonExport } from "../../services/stats" +import { StatsServiceError } from "../../services/stats" +import { getEffectiveCost } from "../../services/stats/costRecalculation" +import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" +import { readTaskMessages } from "../task-persistence/taskMessages" + +// ── Error Codes ───────────────────────────────────────────────────────────── + +export type UsageStatsHandlerErrorCode = + | "STATS_HANDLER/query/001" // invalid payload + | "STATS_HANDLER/query/002" // service unavailable + | "STATS_HANDLER/query/003" // service error + | "STATS_HANDLER/clear/001" // invalid payload (missing nonce) + | "STATS_HANDLER/clear/002" // service unavailable + | "STATS_HANDLER/clear/003" // service error + | "STATS_HANDLER/export/001" // invalid payload + | "STATS_HANDLER/export/002" // service unavailable + | "STATS_HANDLER/export/003" // service error + | "STATS_HANDLER/export/004" // unsupported format + | "STATS_HANDLER/sessions/001" // invalid payload (invalid stats query) + | "STATS_HANDLER/sessions/002" // service unavailable + | "STATS_HANDLER/sessions/003" // service error + | "STATS_HANDLER/sessionDetail/001" // invalid payload (missing taskId) + | "STATS_HANDLER/sessionDetail/002" // service unavailable + | "STATS_HANDLER/sessionDetail/003" // service error + +// ── Handlers ──────────────────────────────────────────────────────────────── + +/** + * Handles the `getUsageStats` message. + * Validates the StatsQuery payload, queries the UsageStatsService, and posts + * the result back to the webview with requestId correlation. + * + * Security: prompt, response, API key, workspace path are never stored or transmitted. + */ +export async function handleGetUsageStats( + provider: ClineProvider, + message: WebviewMessage, +): Promise { + const requestId = message.requestId + + try { + const service = provider.getUsageStatsService() + + if (!service) { + await provider.postMessageToWebview({ + type: "getUsageStatsResponse", + requestId, + error: "[STATS_HANDLER/query/002] Usage stats service is unavailable", + }) + return + } + + // Validate payload + const queryResult = StatsQuerySchema.safeParse(message.usageStatsQuery) + + if (!queryResult.success) { + const errorMsg = queryResult.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join("; ") + + await provider.postMessageToWebview({ + type: "getUsageStatsResponse", + requestId, + error: `[STATS_HANDLER/query/001] Invalid stats query: ${errorMsg}`, + }) + return + } + + const query: StatsQuery = queryResult.data + + const recordingPaused = service.isCapped() + + const snapshot: StatsSnapshot = await service.queryStats(query, { + recordingPaused, + }) + + await provider.postMessageToWebview({ + type: "getUsageStatsResponse", + requestId, + usageStatsSnapshot: snapshot, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + provider.log( + `[STATS_HANDLER/query/003] Error querying usage stats: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + + await provider.postMessageToWebview({ + type: "getUsageStatsResponse", + requestId, + error: `[STATS_HANDLER/query/003] Failed to query usage stats: ${errorMessage}`, + }) + } +} + +/** + * Handles the `clearUsageStats` message. + * Requires a valid confirmation nonce (issued by the service). + * The nonce is short-lived (5 minutes) and single-use. + * + * Security: clear does not touch task history, provider settings, or prompt/response data. + */ +export async function handleClearUsageStats( + provider: ClineProvider, + message: WebviewMessage, +): Promise { + const requestId = message.requestId + + try { + const service = provider.getUsageStatsService() + + if (!service) { + await provider.postMessageToWebview({ + type: "clearUsageStatsResponse", + requestId, + clearUsageStatsResult: { + success: false, + error: "[STATS_HANDLER/clear/002] Usage stats service is unavailable", + }, + }) + return + } + + // Validate nonce + const nonce = message.clearUsageStatsNonce + + if (!nonce || typeof nonce !== "string") { + await provider.postMessageToWebview({ + type: "clearUsageStatsResponse", + requestId, + clearUsageStatsResult: { + success: false, + error: "[STATS_HANDLER/clear/001] Missing or invalid confirmation nonce", + }, + }) + return + } + + await service.clearStats(nonce) + + // Notify all open webviews that stats changed + await provider.postMessageToWebview({ + type: "usageStatsChanged", + }) + + await provider.postMessageToWebview({ + type: "clearUsageStatsResponse", + requestId, + clearUsageStatsResult: { + success: true, + }, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + provider.log( + `[STATS_HANDLER/clear/003] Error clearing usage stats: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + + await provider.postMessageToWebview({ + type: "clearUsageStatsResponse", + requestId, + clearUsageStatsResult: { + success: false, + error: `[STATS_HANDLER/clear/003] Failed to clear usage stats: ${errorMessage}`, + }, + }) + } +} + +/** + * Handles the `exportUsageStats` message. + * Validates the format and query, calls the service to generate export data, + * opens a VS Code save dialog, writes the file, and posts the result back. + * + * Security: the full event array is never sent to the webview. The host writes + * the file directly to the user-selected location. + */ +export async function handleExportUsageStats( + provider: ClineProvider, + message: WebviewMessage, +): Promise { + const requestId = message.requestId + + try { + const service = provider.getUsageStatsService() + + if (!service) { + await provider.postMessageToWebview({ + type: "exportUsageStatsResponse", + requestId, + exportUsageStatsResult: { + format: "json", + data: "", + error: "[STATS_HANDLER/export/002] Usage stats service is unavailable", + }, + }) + return + } + + // Validate format + const format = message.exportUsageStatsFormat + + if (format !== "json" && format !== "csv") { + await provider.postMessageToWebview({ + type: "exportUsageStatsResponse", + requestId, + exportUsageStatsResult: { + format: "json", + data: "", + error: `[STATS_HANDLER/export/004] Unsupported export format: ${String(format)}`, + }, + }) + return + } + + // Validate query + const queryResult = StatsQuerySchema.safeParse(message.usageStatsQuery) + + if (!queryResult.success) { + const errorMsg = queryResult.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join("; ") + + await provider.postMessageToWebview({ + type: "exportUsageStatsResponse", + requestId, + exportUsageStatsResult: { + format, + data: "", + error: `[STATS_HANDLER/export/001] Invalid stats query: ${errorMsg}`, + }, + }) + return + } + + const query: StatsQuery = queryResult.data + + // Generate export data + const exportData = await service.exportStats(query, format) + + // Serialize to file content + const fileContent = + format === "json" + ? JSON.stringify(exportData as JsonExport, null, 2) + : (exportData as string) + + // Determine default file name and extension + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const defaultFileName = `usage-stats-${timestamp}.${format === "json" ? "json" : "csv"}` + + // Resolve default save URI + const defaultUri = await resolveDefaultSaveUri( + provider.contextProxy, + "lastUsageStatsExportPath", + defaultFileName, + { + useWorkspace: false, + fallbackDir: path.join(os.homedir(), "Downloads"), + }, + ) + + // Open save dialog + const saveUri = await vscode.window.showSaveDialog({ + defaultUri, + filters: + format === "json" + ? { "JSON": ["json"] } + : { "CSV": ["csv"] }, + saveLabel: "Export Usage Stats", + }) + + // User cancelled the save dialog — not an error + if (!saveUri) { + await provider.postMessageToWebview({ + type: "exportUsageStatsResponse", + requestId, + exportUsageStatsResult: { + format, + data: "", + }, + }) + return + } + + // Write file + await vscode.workspace.fs.writeFile(saveUri, Buffer.from(fileContent, "utf-8")) + + // Save last export path + await saveLastExportPath(provider.contextProxy, "lastUsageStatsExportPath", saveUri) + + // Post success result (only file name, not full path) + const fileName = path.basename(saveUri.fsPath) + + await provider.postMessageToWebview({ + type: "exportUsageStatsResponse", + requestId, + exportUsageStatsResult: { + format, + data: fileName, + }, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + provider.log( + `[STATS_HANDLER/export/003] Error exporting usage stats: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + + await provider.postMessageToWebview({ + type: "exportUsageStatsResponse", + requestId, + exportUsageStatsResult: { + format: message.exportUsageStatsFormat ?? "json", + data: "", + error: `[STATS_HANDLER/export/003] Failed to export usage stats: ${errorMessage}`, + }, + }) + } +} + +/** + * Handles the `requestClearNonce` message (B2 fix). + * + * Issues a host-generated clear confirmation nonce and posts it back to the + * webview as `requestClearNonceResponse`. The webview must include this nonce + * in the subsequent `clearUsageStats` message. + * + * Previously the webview generated its own nonce, which the host never stored, + * so `clearStats` always failed with "nonce mismatch". The nonce is now + * host-issued, short-lived (5 minutes), and single-use — matching the security + * design intent. + */ +export async function handleRequestClearNonce(provider: ClineProvider, message: WebviewMessage): Promise { + const requestId = message.requestId + + try { + const service = provider.getUsageStatsService() + + if (!service) { + await provider.postMessageToWebview({ + type: "requestClearNonceResponse", + requestId, + clearNonce: null, + error: "[STATS_HANDLER/clear/002] Usage stats service is unavailable", + }) + return + } + + const nonce = service.issueClearNonce() + + await provider.postMessageToWebview({ + type: "requestClearNonceResponse", + requestId, + clearNonce: nonce, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + provider.log( + `[STATS_HANDLER/clear/003] Error issuing clear nonce: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + + await provider.postMessageToWebview({ + type: "requestClearNonceResponse", + requestId, + clearNonce: null, + error: `[STATS_HANDLER/clear/003] Failed to issue clear nonce: ${errorMessage}`, + }) + } +} + +// ── Dashboard Sessions ────────────────────────────────────────────────────── + +/** + * Maximum number of characters used from the first user message when deriving + * a session title. Keeps the session list readable without truncating in the + * UI layer. + */ +const SESSION_TITLE_MAX_LENGTH = 80 + +/** + * Best-effort safe logging helper that does not depend on a provider instance. + * Falls back to `console.warn` so it works in pure utility contexts. + */ +function providerLogSafe(message: string): void { + // Avoid throwing if console is unavailable (defensive). + try { + console.warn(message) + } catch { + // no-op + } +} + +/** + * Derives a human-readable session title from a task's UI messages. + * + * Strategy (best-effort): + * 1. Read `ui_messages.json` for the task via `readTaskMessages`. + * 2. Find the first `ClineMessage` whose `type === "say"` and whose `say` is + * either `"user_feedback"` (a user-typed follow-up) or `"text"` / `"task"` + * (the initial task prompt). The `text` field of that message is the title. + * 3. Truncate to {@link SESSION_TITLE_MAX_LENGTH} characters (first line only). + * 4. If no user message is found, fall back to a truncated taskId. + * + * Security: only the `text` field of UI messages is read. No prompt bodies, + * response bodies, or API keys are accessed. + */ +async function deriveSessionTitle(taskId: string, globalStoragePath: string): Promise { + try { + const messages = await readTaskMessages({ taskId, globalStoragePath }) + + for (const msg of messages) { + if (msg.type !== "say") continue + if (msg.say !== "user_feedback" && msg.say !== "text" && msg.say !== "task") continue + const raw = (msg.text ?? "").trim() + if (!raw) continue + // Use only the first line to keep the title compact. + const firstLine = raw.split(/\r?\n/, 1)[0] ?? raw + if (firstLine.length <= SESSION_TITLE_MAX_LENGTH) return firstLine + return `${firstLine.slice(0, SESSION_TITLE_MAX_LENGTH - 1)}\u2026` + } + } catch (error) { + // Title extraction is best-effort; never fail the whole request. + providerLogSafe( + `[STATS_HANDLER/sessions/003] Failed to read task messages for title (taskId=${taskId}): ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + + // Fallback: truncated taskId + return taskId.length > SESSION_TITLE_MAX_LENGTH + ? `${taskId.slice(0, SESSION_TITLE_MAX_LENGTH - 1)}\u2026` + : taskId +} + +/** + * Resolves the root task ID for a usage event. + * + * Feature 2: Sessions should be grouped by conversation session (root task), + * not by individual subtask. A subtask has a `parentTaskId` pointing to its + * parent. By following the parent chain, we can group all subtasks under + * their root conversation session. + * + * Since the event only carries its immediate `parentTaskId` (not the full + * chain), we build a parent→children map from the event set and walk up + * the chain. If an event has no `parentTaskId`, it IS the root. + * + * @param event The usage event to resolve. + * @param parentMap Map of taskId → parentTaskId (built from the event set). + * @returns The root task ID for grouping. + */ +function resolveRootTaskId(event: UsageEventV1, parentMap: Map): string { + let current = event.taskId + const visited = new Set() // Guard against cycles + + while (!visited.has(current)) { + visited.add(current) + const parent = parentMap.get(current) + if (!parent) break // No parent → this is the root + current = parent + } + + return current +} + +/** + * Builds a map of taskId → parentTaskId from a set of usage events. + * This allows resolving the root task for any event in the set. + */ +function buildParentMap(events: UsageEventV1[]): Map { + const parentMap = new Map() + for (const event of events) { + if (!parentMap.has(event.taskId)) { + parentMap.set(event.taskId, event.parentTaskId) + } + } + return parentMap +} + +/** + * Groups usage events by their root conversation session and produces a + * {@link SessionSummary} for each group. + * + * Feature 2: Events are grouped by root task ID (following `parentTaskId` + * chains) so that subtasks appear under their parent conversation session. + * If an event has no `parentTaskId`, it is its own root. + * + * Feature 1: Missing `costUsd` values are computed on-the-fly using the + * model's pricing info. The NDJSON store is never modified. + * + * The summary uses the first event's model/provider/mode as representative + * values (a session may span multiple models, but the first event is a + * reasonable proxy for display purposes). + * + * @param events Filtered usage events (already scoped to the requested time + * range and `includeCancelled` policy). + * @param globalStoragePath Used to read task messages for title extraction. + */ +async function buildSessionSummaries( + events: UsageEventV1[], + globalStoragePath: string, +): Promise { + // Feature 2: Build parent map and group by root task ID. + const parentMap = buildParentMap(events) + + // Group events by root taskId, preserving insertion order for determinism. + const groups = new Map() + for (const event of events) { + const rootTaskId = resolveRootTaskId(event, parentMap) + const list = groups.get(rootTaskId) + if (list) { + list.push(event) + } else { + groups.set(rootTaskId, [event]) + } + } + + const summaries: SessionSummary[] = [] + + for (const [taskId, taskEvents] of groups) { + // Sort events within a task by occurredAt ascending so the first + // event is the earliest (representative model/provider/mode) and + // the last event gives the most recent activity timestamp. + const sorted = [...taskEvents].sort( + (a, b) => new Date(a.occurredAt).getTime() - new Date(b.occurredAt).getTime(), + ) + + const first = sorted[0] + const last = sorted[sorted.length - 1] + + // Aggregate totals across all events in the task. + // Feature 1: Use getEffectiveCost to compute missing costs on-the-fly. + let totalTokens = 0 + let totalCost = 0 + for (const ev of sorted) { + totalTokens += ev.usage.totalTokens?.value ?? 0 + totalCost += getEffectiveCost(ev) + } + + const title = await deriveSessionTitle(taskId, globalStoragePath) + + summaries.push({ + taskId, + title, + timestamp: new Date(last.occurredAt).getTime(), + model: first.model, + provider: first.provider, + mode: first.mode, + // Preserve first-seen order across the session's events so that + // multi-model/multi-mode sessions (e.g. orchestrator delegations) + // are fully represented. `model`/`mode` above keep the earliest + // value for backward compatibility. + models: [...new Set(sorted.map((e) => e.model))], + modes: [...new Set(sorted.map((e) => e.mode))], + totalTokens, + totalCost, + callCount: sorted.length, + }) + } + + // Sort sessions by timestamp descending (most recent first). + summaries.sort((a, b) => b.timestamp - a.timestamp) + + return summaries +} + +/** + * Handles the `getDashboardSessions` message. + * + * Reads the time-range query (reusing the existing `StatsQuery` validation + * infrastructure), queries the `UsageStatsService` for raw events, groups + * them by `taskId` into {@link SessionSummary} entries, applies optional + * model/provider filters, and posts the result back to the webview as + * `dashboardSessionsResponse`. + * + * The session title is derived best-effort from the task's UI messages; if + * unavailable, a truncated taskId is used as the title. + * + * Security: only `taskId`, model/provider/mode, token totals, cost, and the + * first user message text (truncated) are sent to the webview. No prompt + * bodies, response bodies, or API keys are transmitted. + */ +export async function handleGetDashboardSessions( + provider: ClineProvider, + message: WebviewMessage, +): Promise { + const requestId = message.requestId + + try { + const service = provider.getUsageStatsService() + + if (!service) { + await provider.postMessageToWebview({ + type: "dashboardSessionsResponse", + requestId, + dashboardSessions: null, + error: "[STATS_HANDLER/sessions/002] Usage stats service is unavailable", + }) + return + } + + // Validate the stats query payload (time range + timezone + groupBy). + // `groupBy` is required by the schema but irrelevant for session + // grouping; the caller still has to provide a valid value. + const queryResult = StatsQuerySchema.safeParse(message.usageStatsQuery) + + if (!queryResult.success) { + const errorMsg = queryResult.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join("; ") + + await provider.postMessageToWebview({ + type: "dashboardSessionsResponse", + requestId, + dashboardSessions: null, + error: `[STATS_HANDLER/sessions/001] Invalid stats query: ${errorMsg}`, + }) + return + } + + const query: StatsQuery = queryResult.data + + // Use the cached events directly instead of export→JSON→parse. This + // preserves the same time-range and includeCancelled filtering while + // avoiding an unnecessary serialize/parse round-trip. + const events = await service.getFilteredEvents(query) + + const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath + + let summaries = await buildSessionSummaries(events, globalStoragePath) + + // Apply optional model/provider filters (post-grouping). + // The model filter checks `models` (the full set used in the session) + // so that sessions which switched models are still matched; it falls + // back to the legacy `model` field when `models` is absent. + const filters = message.dashboardSessionFilters + if (filters?.model) { + summaries = summaries.filter( + (s) => s.models?.includes(filters.model!) ?? s.model === filters.model, + ) + } + if (filters?.provider) { + summaries = summaries.filter((s) => s.provider === filters.provider) + } + + await provider.postMessageToWebview({ + type: "dashboardSessionsResponse", + requestId, + dashboardSessions: summaries, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + provider.log( + `[STATS_HANDLER/sessions/003] Error querying dashboard sessions: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + + await provider.postMessageToWebview({ + type: "dashboardSessionsResponse", + requestId, + dashboardSessions: null, + error: `[STATS_HANDLER/sessions/003] Failed to query dashboard sessions: ${errorMessage}`, + }) + } +} + +// ── Dashboard Session Detail ───────────────────────────────────────────────── + +/** + * Maps a single {@link UsageEventV1} to an {@link APICallRecord} for display + * in the session detail expansion. Only the fields needed by the UI are + * projected; prompt bodies, response bodies, API keys, and workspace paths + * are never included. + * + * @param event The raw usage event. + * @param index The 1-based index of the event within its task (for display). + */ +function mapEventToApiCall(event: UsageEventV1, index: number): APICallRecord { + return { + index, + mode: event.mode, + timestamp: new Date(event.occurredAt).getTime(), + inputTokens: event.usage.inputTokens?.value ?? 0, + outputTokens: event.usage.outputTokens?.value ?? 0, + cacheReadTokens: event.usage.cacheReadTokens?.value ?? 0, + cacheWriteTokens: event.usage.cacheWriteTokens?.value ?? 0, + reasoningTokens: event.usage.reasoningTokens?.value ?? 0, + // Feature 1: Compute missing cost on-the-fly from model pricing. + costUsd: getEffectiveCost(event), + status: event.status, + model: event.model, + } +} + +/** + * Builds a {@link SessionDetail} from the raw usage events for a single task. + * + * The summary fields mirror {@link buildSessionSummaries} so the expanded + * detail header matches the row summary the user clicked. The `apiCalls` array + * is sorted by `occurredAt` ascending (oldest first) so the index column + * reflects chronological order within the session. + * + * @param taskId The task identifier to build the detail for. + * @param events The raw usage events filtered to this task. + * @param globalStoragePath Used to read task messages for title extraction. + */ +async function buildSessionDetail( + taskId: string, + events: UsageEventV1[], + globalStoragePath: string, +): Promise { + // Sort events by occurredAt ascending so index reflects chronological order. + const sorted = [...events].sort( + (a, b) => new Date(a.occurredAt).getTime() - new Date(b.occurredAt).getTime(), + ) + + const first = sorted[0] + const last = sorted[sorted.length - 1] + + // Aggregate totals across all events in the task. + // Feature 1: Use getEffectiveCost to compute missing costs on-the-fly. + let totalTokens = 0 + let totalCost = 0 + for (const ev of sorted) { + totalTokens += ev.usage.totalTokens?.value ?? 0 + totalCost += getEffectiveCost(ev) + } + + const title = await deriveSessionTitle(taskId, globalStoragePath) + + const apiCalls: APICallRecord[] = sorted.map((event, i) => mapEventToApiCall(event, i + 1)) + + return { + taskId, + title, + timestamp: new Date(last.occurredAt).getTime(), + model: first.model, + provider: first.provider, + mode: first.mode, + // Mirror buildSessionSummaries: capture every unique model/mode in + // first-seen order so the detail view can show the full set. + models: [...new Set(sorted.map((e) => e.model))], + modes: [...new Set(sorted.map((e) => e.mode))], + totalTokens, + totalCost, + callCount: sorted.length, + apiCalls, + } +} + +/** + * Handles the `getDashboardSessionDetail` message (Commit 4). + * + * Reads the `taskId` from the message, queries the `UsageStatsService` for all + * raw events (using a permissive time-range query so every event for the task + * is returned), filters to the requested `taskId`, builds a {@link SessionDetail} + * with per-API-call records, and posts the result back to the webview as + * `dashboardSessionDetailResponse`. + * + * The query reuses `exportStats` with the "all" preset (no from/to bounds) so + * the detail is not clipped by the dashboard's current time-range selection. + * This matches user expectations: clicking a session row shows the full + * session, not just the portion within the current range. + * + * Security: only `taskId`, model/provider/mode, token totals, cost, status, + * timestamps, and the first user message text (truncated) are sent to the + * webview. No prompt bodies, response bodies, or API keys are transmitted. + */ +export async function handleGetDashboardSessionDetail( + provider: ClineProvider, + message: WebviewMessage, +): Promise { + const requestId = message.requestId + + try { + const service = provider.getUsageStatsService() + + if (!service) { + await provider.postMessageToWebview({ + type: "dashboardSessionDetailResponse", + requestId, + dashboardSessionDetail: null, + error: "[STATS_HANDLER/sessionDetail/002] Usage stats service is unavailable", + }) + return + } + + // The taskId is carried in `message.text` (the conventional field for + // single-string payloads in WebviewMessage). It is also accepted via + // `message.taskId` for explicitness. + const taskId = message.taskId ?? message.text + + if (!taskId || typeof taskId !== "string") { + await provider.postMessageToWebview({ + type: "dashboardSessionDetailResponse", + requestId, + dashboardSessionDetail: null, + error: "[STATS_HANDLER/sessionDetail/001] Missing or invalid taskId", + }) + return + } + + // Query all events (no time-range bounds) so the session detail is + // not clipped by the dashboard's current range selection. The + // `includeCancelled` flag is true so failed/cancelled calls appear in + // the per-call list (the summary already excludes them from totals + // when the dashboard range filters them out, but the detail view + // should show every call that happened in the session). + const allQuery: StatsQuery = { + preset: "all", + timezone: "UTC", + groupBy: ["model"], + includeCancelled: true, + } + + // Query all events directly to avoid the export→JSON→parse round-trip. + const allEvents = await service.getFilteredEvents(allQuery) + + // Feature 2: Filter to the requested root task AND its subtasks. + // The session list groups events by root task ID, so clicking a + // session row passes the root task ID. We need to include events + // from all subtasks whose root resolves to this taskId. + const parentMap = buildParentMap(allEvents) + const taskEvents = allEvents.filter((ev) => resolveRootTaskId(ev, parentMap) === taskId) + + if (taskEvents.length === 0) { + // No events for this task — return an empty detail rather than an + // error so the UI can render the "no API calls" empty state. + const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath + const title = await deriveSessionTitle(taskId, globalStoragePath) + + await provider.postMessageToWebview({ + type: "dashboardSessionDetailResponse", + requestId, + dashboardSessionDetail: { + taskId, + title, + timestamp: 0, + model: "", + provider: "", + mode: "", + models: [], + modes: [], + totalTokens: 0, + totalCost: 0, + callCount: 0, + apiCalls: [], + }, + }) + return + } + + const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath + const detail = await buildSessionDetail(taskId, taskEvents, globalStoragePath) + + await provider.postMessageToWebview({ + type: "dashboardSessionDetailResponse", + requestId, + dashboardSessionDetail: detail, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + provider.log( + `[STATS_HANDLER/sessionDetail/003] Error querying dashboard session detail: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + + await provider.postMessageToWebview({ + type: "dashboardSessionDetailResponse", + requestId, + dashboardSessionDetail: null, + error: `[STATS_HANDLER/sessionDetail/003] Failed to query dashboard session detail: ${errorMessage}`, + }) + } +} + +// Re-export StatsServiceError for convenience in tests +export { StatsServiceError } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c3918e24a5..cedb47b761 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -49,6 +49,14 @@ import { handleOpenRuleFile, handleOpenRulesDirectory, } from "./rulesMessageHandler" +import { + handleGetUsageStats, + handleClearUsageStats, + handleExportUsageStats, + handleRequestClearNonce, + handleGetDashboardSessions, + handleGetDashboardSessionDetail, +} from "./usageStatsMessageHandler" import { changeLanguage, t } from "../../i18n" import { Package } from "../../shared/package" import { type RouterName, toRouterName } from "../../shared/api" @@ -3933,6 +3941,36 @@ export const webviewMessageHandler = async ( break } + case "getUsageStats": { + await handleGetUsageStats(provider, message) + break + } + + case "clearUsageStats": { + await handleClearUsageStats(provider, message) + break + } + + case "requestClearNonce": { + await handleRequestClearNonce(provider, message) + break + } + + case "exportUsageStats": { + await handleExportUsageStats(provider, message) + break + } + + case "getDashboardSessions": { + await handleGetDashboardSessions(provider, message) + break + } + + case "getDashboardSessionDetail": { + await handleGetDashboardSessionDetail(provider, message) + break + } + default: { // console.log(`Unhandled message type: ${message.type}`) // diff --git a/src/integrations/terminal/TerminalProcess.ts b/src/integrations/terminal/TerminalProcess.ts index d1643dec3a..21cc20f988 100644 --- a/src/integrations/terminal/TerminalProcess.ts +++ b/src/integrations/terminal/TerminalProcess.ts @@ -95,18 +95,22 @@ export class TerminalProcess extends BaseTerminalProcess { // Remove event listener to prevent memory leaks this.removeAllListeners("stream_available") - // Emit no_shell_integration event with descriptive message - this.emit("no_shell_integration", { - message: `VSCE shell integration stream did not start within ${Terminal.getShellIntegrationTimeout() / 1000} seconds. Terminal problem?`, - commandSubmitted: true, - }) - - // Reject with descriptive error - reject( - new Error( - `VSCE shell integration stream did not start within ${Terminal.getShellIntegrationTimeout() / 1000} seconds.`, - ), - ) + // Emit no_shell_integration event with commandSubmitted: true so the + // ExecuteCommandTool catch block retries via execa fallback. The command + // was already submitted to the terminal (executeCommand() returned), + // so the original may still be running — the retried execa output may + // duplicate or differ, but a result is always better than a dead-end. + this.emit("no_shell_integration", { + message: `VSCE shell integration stream did not start within ${Terminal.getShellIntegrationTimeout() / 1000} seconds. The command was submitted but output tracking was lost; retrying with fallback executor.`, + commandSubmitted: true, + }) + + // Reject with descriptive error + reject( + new Error( + `VSCE shell integration stream did not start within ${Terminal.getShellIntegrationTimeout() / 1000} seconds.`, + ), + ) }, Terminal.getShellIntegrationTimeout()) cancelStreamWait = () => { diff --git a/src/package.json b/src/package.json index 7eb12d5fde..09803fbac0 100644 --- a/src/package.json +++ b/src/package.json @@ -95,6 +95,11 @@ "title": "%command.settings.title%", "icon": "$(settings-gear)" }, + { + "command": "zoo-code.dashboardButtonClicked", + "title": "%command.dashboard.title%", + "icon": "$(graph)" + }, { "command": "zoo-code.openInNewTab", "title": "%command.openInNewTab.title%", @@ -228,6 +233,11 @@ "group": "navigation@3", "when": "view == zoo-code.SidebarProvider" }, + { + "command": "zoo-code.dashboardButtonClicked", + "group": "navigation@4", + "when": "view == zoo-code.SidebarProvider" + }, { "command": "zoo-code.historyButtonClicked", "group": "overflow@1", @@ -255,6 +265,11 @@ "group": "navigation@3", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, + { + "command": "zoo-code.dashboardButtonClicked", + "group": "navigation@4", + "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + }, { "command": "zoo-code.historyButtonClicked", "group": "overflow@1", diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json index 6ddaf181b4..beee59a370 100644 --- a/src/package.nls.ca.json +++ b/src/package.nls.ca.json @@ -16,6 +16,7 @@ "command.acceptInput.title": "Acceptar Entrada/Suggeriment", "command.showRipgrepDiagnostic.title": "Mostra el diagnòstic de Ripgrep", "command.toggleAutoApprove.title": "Alternar Auto-Aprovació", + "command.dashboard.title": "Dashboard", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.de.json b/src/package.nls.de.json index 4c8eccb293..b309644c3e 100644 --- a/src/package.nls.de.json +++ b/src/package.nls.de.json @@ -16,6 +16,7 @@ "command.acceptInput.title": "Eingabe/Vorschlag Akzeptieren", "command.showRipgrepDiagnostic.title": "Ripgrep-Diagnose anzeigen", "command.toggleAutoApprove.title": "Auto-Genehmigung Umschalten", + "command.dashboard.title": "Dashboard", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.es.json b/src/package.nls.es.json index 11a705880b..2f36fca243 100644 --- a/src/package.nls.es.json +++ b/src/package.nls.es.json @@ -16,6 +16,7 @@ "command.acceptInput.title": "Aceptar Entrada/Sugerencia", "command.showRipgrepDiagnostic.title": "Mostrar diagnóstico de Ripgrep", "command.toggleAutoApprove.title": "Alternar Auto-Aprobación", + "command.dashboard.title": "Dashboard", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json index 573350bc9a..b9a3fec755 100644 --- a/src/package.nls.fr.json +++ b/src/package.nls.fr.json @@ -16,6 +16,7 @@ "command.acceptInput.title": "Accepter l'Entrée/Suggestion", "command.showRipgrepDiagnostic.title": "Afficher le diagnostic Ripgrep", "command.toggleAutoApprove.title": "Basculer Auto-Approbation", + "command.dashboard.title": "Dashboard", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json index 8135af2ab3..e5847cfec1 100644 --- a/src/package.nls.hi.json +++ b/src/package.nls.hi.json @@ -16,6 +16,7 @@ "command.acceptInput.title": "इनपुट/सुझाव स्वीकारें", "command.showRipgrepDiagnostic.title": "Ripgrep डायग्नोस्टिक दिखाएं", "command.toggleAutoApprove.title": "ऑटो-अनुमोदन टॉगल करें", + "command.dashboard.title": "Dashboard", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.id.json b/src/package.nls.id.json index c5740ad00b..82de0f73b0 100644 --- a/src/package.nls.id.json +++ b/src/package.nls.id.json @@ -25,6 +25,7 @@ "command.acceptInput.title": "Terima Input/Saran", "command.showRipgrepDiagnostic.title": "Tampilkan Diagnostik Ripgrep", "command.toggleAutoApprove.title": "Alihkan Persetujuan Otomatis", + "command.dashboard.title": "Dashboard", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Perintah yang dapat dijalankan secara otomatis ketika 'Selalu setujui operasi eksekusi' diaktifkan", "commands.deniedCommands.description": "Awalan perintah yang akan otomatis ditolak tanpa meminta persetujuan. Jika terjadi konflik dengan perintah yang diizinkan, pencocokan awalan terpanjang akan diprioritaskan. Tambahkan * untuk menolak semua perintah.", diff --git a/src/package.nls.it.json b/src/package.nls.it.json index ebf2167a99..011b60269b 100644 --- a/src/package.nls.it.json +++ b/src/package.nls.it.json @@ -16,6 +16,7 @@ "command.acceptInput.title": "Accetta Input/Suggerimento", "command.showRipgrepDiagnostic.title": "Mostra diagnostica Ripgrep", "command.toggleAutoApprove.title": "Attiva/Disattiva Auto-Approvazione", + "command.dashboard.title": "Dashboard", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json index f9daa4bb93..d4b1e287a3 100644 --- a/src/package.nls.ja.json +++ b/src/package.nls.ja.json @@ -25,6 +25,7 @@ "command.acceptInput.title": "入力/提案を承認", "command.showRipgrepDiagnostic.title": "Ripgrep 診断を表示", "command.toggleAutoApprove.title": "自動承認を切替", + "command.dashboard.title": "Dashboard", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "'常に実行操作を承認する'が有効な場合に自動実行できるコマンド", "commands.deniedCommands.description": "承認を求めずに自動的に拒否されるコマンドプレフィックス。許可されたコマンドとの競合がある場合、最長プレフィックスマッチが優先されます。すべてのコマンドを拒否するには * を追加してください。", diff --git a/src/package.nls.json b/src/package.nls.json index 4fac644eab..e9e9ddbd45 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -25,6 +25,7 @@ "command.acceptInput.title": "Accept Input/Suggestion", "command.showRipgrepDiagnostic.title": "Show Ripgrep Diagnostic", "command.toggleAutoApprove.title": "Toggle Auto-Approve", + "command.dashboard.title": "Dashboard", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled", "commands.deniedCommands.description": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.", diff --git a/src/package.nls.ko.json b/src/package.nls.ko.json index a743902280..821a7a35b7 100644 --- a/src/package.nls.ko.json +++ b/src/package.nls.ko.json @@ -16,6 +16,7 @@ "command.acceptInput.title": "입력/제안 수락", "command.showRipgrepDiagnostic.title": "Ripgrep 진단 표시", "command.toggleAutoApprove.title": "자동 승인 전환", + "command.dashboard.title": "Dashboard", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json index 72bc15f89a..8ab3924cf4 100644 --- a/src/package.nls.nl.json +++ b/src/package.nls.nl.json @@ -25,6 +25,7 @@ "command.acceptInput.title": "Invoer/Suggestie Accepteren", "command.showRipgrepDiagnostic.title": "Ripgrep-diagnose weergeven", "command.toggleAutoApprove.title": "Auto-Goedkeuring Schakelen", + "command.dashboard.title": "Dashboard", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Commando's die automatisch kunnen worden uitgevoerd wanneer 'Altijd goedkeuren uitvoerbewerkingen' is ingeschakeld", "commands.deniedCommands.description": "Commando-prefixen die automatisch worden geweigerd zonder om goedkeuring te vragen. Bij conflicten met toegestane commando's heeft de langste prefix-match voorrang. Voeg * toe om alle commando's te weigeren.", diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json index 92fb97778b..2b6443926b 100644 --- a/src/package.nls.pl.json +++ b/src/package.nls.pl.json @@ -16,6 +16,7 @@ "command.acceptInput.title": "Akceptuj Wprowadzanie/Sugestię", "command.showRipgrepDiagnostic.title": "Pokaż diagnostykę Ripgrep", "command.toggleAutoApprove.title": "Przełącz Auto-Zatwierdzanie", + "command.dashboard.title": "Dashboard", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json index 872af10e80..cdb7afdc56 100644 --- a/src/package.nls.pt-BR.json +++ b/src/package.nls.pt-BR.json @@ -16,6 +16,7 @@ "command.acceptInput.title": "Aceitar Entrada/Sugestão", "command.showRipgrepDiagnostic.title": "Mostrar diagnóstico do Ripgrep", "command.toggleAutoApprove.title": "Alternar Auto-Aprovação", + "command.dashboard.title": "Dashboard", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json index cb38655945..afda789158 100644 --- a/src/package.nls.ru.json +++ b/src/package.nls.ru.json @@ -25,6 +25,7 @@ "command.acceptInput.title": "Принять ввод/предложение", "command.showRipgrepDiagnostic.title": "Показать диагностику Ripgrep", "command.toggleAutoApprove.title": "Переключить Авто-Подтверждение", + "command.dashboard.title": "Dashboard", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Команды, которые могут быть автоматически выполнены, когда включена опция 'Всегда подтверждать операции выполнения'", "commands.deniedCommands.description": "Префиксы команд, которые будут автоматически отклонены без запроса подтверждения. В случае конфликтов с разрешенными командами приоритет имеет самое длинное совпадение префикса. Добавьте * чтобы отклонить все команды.", diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json index 7d995723ce..8b5f145fb3 100644 --- a/src/package.nls.tr.json +++ b/src/package.nls.tr.json @@ -16,6 +16,7 @@ "command.acceptInput.title": "Girişi/Öneriyi Kabul Et", "command.showRipgrepDiagnostic.title": "Ripgrep Tanılamasını Göster", "command.toggleAutoApprove.title": "Otomatik Onayı Değiştir", + "command.dashboard.title": "Dashboard", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json index b50e4db508..0d6c0f719a 100644 --- a/src/package.nls.vi.json +++ b/src/package.nls.vi.json @@ -16,6 +16,7 @@ "command.acceptInput.title": "Chấp Nhận Đầu Vào/Gợi Ý", "command.showRipgrepDiagnostic.title": "Hiển thị chẩn đoán Ripgrep", "command.toggleAutoApprove.title": "Bật/Tắt Tự Động Phê Duyệt", + "command.dashboard.title": "Dashboard", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index 0686d03a14..8e85f0deb8 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -16,6 +16,7 @@ "command.acceptInput.title": "接受输入/建议", "command.showRipgrepDiagnostic.title": "显示 Ripgrep 诊断", "command.toggleAutoApprove.title": "切换自动批准", + "command.dashboard.title": "Dashboard", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index 8005e0de7f..df0732e0c3 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -16,6 +16,7 @@ "command.acceptInput.title": "接受輸入/建議", "command.showRipgrepDiagnostic.title": "顯示 Ripgrep 診斷", "command.toggleAutoApprove.title": "切換自動批准", + "command.dashboard.title": "Dashboard", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/services/command/__tests__/built-in-commands.spec.ts b/src/services/command/__tests__/built-in-commands.spec.ts index ecb2bdb0fb..936f908a0b 100644 --- a/src/services/command/__tests__/built-in-commands.spec.ts +++ b/src/services/command/__tests__/built-in-commands.spec.ts @@ -99,5 +99,6 @@ describe("Built-in Commands", () => { expect(content).toContain("rules-ask") expect(content).toContain("rules-architect") }) + }) }) diff --git a/src/services/stats/UsageAggregator.ts b/src/services/stats/UsageAggregator.ts new file mode 100644 index 0000000000..0f6852b3f6 --- /dev/null +++ b/src/services/stats/UsageAggregator.ts @@ -0,0 +1,586 @@ +import type { + UsageEventV1, + StatsQuery, + StatsSnapshot, + StatsBucket, + SourcedNumber, + UsageValueSource, +} from "@roo-code/types" + +import { getEffectiveCost, computeEventCost } from "./costRecalculation" + +// ── Types ─────────────────────────────────────────────────────────────────── + +/** Internal event representation used for aggregation (UsageEventV1 + derived fields) */ +interface AggregatableEvent { + event: UsageEventV1 + /** Calendar bucket key based on timezone (e.g. "2026-07-19") */ + dayBucket?: string + /** Calendar week bucket key based on timezone (e.g. "2026-W29") */ + weekBucket?: string + /** Calendar month bucket key based on timezone (e.g. "2026-07") */ + monthBucket?: string +} + +/** Internal structure for separating cost by source */ +interface SourceSeparatedCost { + provider: number + estimated: number + backfilled: number +} + +// ── Empty Bucket Factory ──────────────────────────────────────────────────── + +function createEmptyBucket(key: Record = {}): StatsBucket { + return { + key, + events: 0, + completedCalls: 0, + failedCalls: 0, + cancelledCalls: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + costUsd: 0, + unknownEventCount: 0, + } +} + +// ── UsageAggregator ──────────────────────────────────────────────────────── + +/** + * Usage event aggregation engine. + * + * Design principles (architecture report section 5.17): + * - Group by day/week/month/provider/model/mode/status/source (up to 3 axes) + * - Timezone calendar bucket (DST handling) + * - Separate unknown fields (unknownEventCount) + * - Separate cost by source (provider/estimated/backfilled) + * - Handle inclusion semantics (cacheReadInInput etc.) + * - Result sorting: time ascending, category by known total descending then name ascending + */ +export class UsageAggregator { + /** + * Aggregates an array of events according to the query conditions and returns a StatsSnapshot. + * + * @param events Array of events to aggregate (result of UsageEventStore.readAll()) + * @param query Statistics query + * @param options Additional options (e.g. recordingPaused) + */ + query(events: UsageEventV1[], query: StatsQuery, options: { recordingPaused?: boolean } = {}): StatsSnapshot { + // 1. Time range filtering + const { from, to } = this.resolveTimeRange(query) + const filtered = events.filter((event) => { + const eventTime = new Date(event.occurredAt).getTime() + if (from && eventTime < from.getTime()) return false + if (to && eventTime >= to.getTime()) return false + return true + }) + + // 2. Cancelled event filtering + const includeCancelled = query.includeCancelled ?? false + const visibleEvents = includeCancelled ? filtered : filtered.filter((e) => e.status !== "cancelled") + + // 3. Compute bucket keys based on timezone + const aggregatable: AggregatableEvent[] = visibleEvents.map((event) => { + const bucketKeys = this.computeTimeBuckets(event, query.timezone) + return { event, ...bucketKeys } + }) + + // 4. Grouping and aggregation + const groupBy = query.groupBy + const bucketMap = new Map() + + for (const item of aggregatable) { + const bucketKeys = this.getGroupKeys(item, groupBy) + for (const bucketKey of bucketKeys) { + const mapKey = this.serializeKey(bucketKey) + let bucket = bucketMap.get(mapKey) + if (!bucket) { + bucket = createEmptyBucket(bucketKey) + bucketMap.set(mapKey, bucket) + } + this.accumulateIntoBucket(bucket, item.event) + } + } + + // 5. Compute totals + const totals = createEmptyBucket() + for (const item of aggregatable) { + this.accumulateIntoBucket(totals, item.event) + } + + // 6. Sorting + const buckets = this.sortBuckets(Array.from(bucketMap.values()), groupBy) + + // 7. Compute coverage + const coverage = this.computeCoverage(events, aggregatable, options.recordingPaused) + + return { + query, + generatedAt: new Date().toISOString(), + buckets, + totals, + coverage, + } + } + + // ── Time Range Resolution ─────────────────────────────────────────────── + + /** + * Determines the time range based on the query's preset/from/to. + * - today: from 00:00 today in the query timezone up to (but not including) 00:00 the next day + * - 7d/30d: 7/30 calendar days including today + * - all: all supported events + */ + private resolveTimeRange(query: StatsQuery): { from?: Date; to?: Date } { + if (query.preset) { + const now = new Date() + const tzNow = this.toTimezoneDate(now, query.timezone) + + switch (query.preset) { + case "today": { + const from = this.startOfDay(tzNow, query.timezone) + const to = new Date(from) + to.setDate(to.getDate() + 1) + return { from, to } + } + case "7d": { + const to = this.startOfDay(tzNow, query.timezone) + to.setDate(to.getDate() + 1) + const from = new Date(to) + from.setDate(from.getDate() - 7) + return { from, to } + } + case "30d": { + const to = this.startOfDay(tzNow, query.timezone) + to.setDate(to.getDate() + 1) + const from = new Date(to) + from.setDate(from.getDate() - 30) + return { from, to } + } + case "all": + return {} + } + } + + // Explicit from/to + const from = query.from ? new Date(query.from) : undefined + const to = query.to ? new Date(query.to) : undefined + return { from, to } + } + + /** + * Converts a UTC Date to the same instant in the specified timezone. + * Uses the Intl API to handle DST automatically. + */ + private toTimezoneDate(date: Date, timezone: string): Date { + // Get the wall-clock time in the timezone + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }) + + const parts = formatter.formatToParts(date) + const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "0" + const year = parseInt(get("year"), 10) + const month = parseInt(get("month"), 10) - 1 + const day = parseInt(get("day"), 10) + const hour = parseInt(get("hour"), 10) % 24 // Convert 24-hour to 0-hour + const minute = parseInt(get("minute"), 10) + const second = parseInt(get("second"), 10) + + // Convert timezone wall-clock time to UTC + // tzOffset = UTC - (timezone wall-clock as UTC) + // Actual UTC of timezone wall-clock = wall-clock as UTC + tzOffset + const utcGuess = Date.UTC(year, month, day, hour, minute, second) + const tzOffset = this.getTimezoneOffsetMinutes(date, timezone) + return new Date(utcGuess + tzOffset * 60 * 1000) + } + + /** + * Returns the UTC offset for the specified timezone in minutes. + */ + private getTimezoneOffsetMinutes(date: Date, timezone: string): number { + // Format the UTC time in the timezone + const utcDate = new Date(date.toISOString()) + const tzFormatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }) + const tzParts = tzFormatter.formatToParts(utcDate) + const get = (type: string) => parseInt(tzParts.find((p) => p.type === type)?.value ?? "0", 10) + const tzYear = get("year") + const tzMonth = get("month") - 1 + const tzDay = get("day") + const tzHour = get("hour") % 24 + const tzMinute = get("minute") + const tzSecond = get("second") + + // Convert timezone wall-clock to UTC epoch + const tzEpoch = Date.UTC(tzYear, tzMonth, tzDay, tzHour, tzMinute, tzSecond) + // offset = UTC epoch - timezone epoch (in minutes) + // If the timezone is ahead of UTC (e.g. Asia/Seoul = +9), tzEpoch is less than the UTC epoch + // offset = (utcEpoch - tzEpoch) / 60000 + return Math.round((utcDate.getTime() - tzEpoch) / 60000) + } + + /** + * Returns the 00:00:00 UTC for the given date based on the timezone. + */ + private startOfDay(date: Date, timezone: string): Date { + const tzDate = this.toTimezoneDate(date, timezone) + // Extract only the wall-clock date in the timezone + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + const parts = formatter.formatToParts(date) + const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "0" + const year = parseInt(get("year"), 10) + const month = parseInt(get("month"), 10) - 1 + const day = parseInt(get("day"), 10) + + // Convert 00:00:00 in the timezone to UTC + const midnightEpoch = Date.UTC(year, month, day, 0, 0, 0) + const tzOffset = this.getTimezoneOffsetMinutes(date, timezone) + // tzOffset = UTC - (timezone wall-clock as UTC) + // Actual UTC of timezone midnight = timezone midnight wall-clock as UTC + tzOffset + return new Date(midnightEpoch + tzOffset * 60 * 1000) + } + + // ── Time Bucket Computation ───────────────────────────────────────────── + + /** + * Computes calendar bucket keys for an event based on the timezone. + * DST is handled automatically by the Intl API. + */ + private computeTimeBuckets( + event: UsageEventV1, + timezone: string, + ): { dayBucket?: string; weekBucket?: string; monthBucket?: string } { + const date = new Date(event.occurredAt) + + // day bucket: YYYY-MM-DD (timezone-based) + const dayFormatter = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + const dayBucket = dayFormatter.format(date).replace(/\//g, "-") + + // month bucket: YYYY-MM + const monthFormatter = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + }) + const monthBucket = monthFormatter.format(date).replace(/\//g, "-") + + // week bucket: YYYY-Www (ISO week) + const weekBucket = this.computeIsoWeekBucket(date, timezone) + + return { dayBucket, weekBucket, monthBucket } + } + + /** + * Computes the ISO 8601 week number (YYYY-Www format). + * Calculated based on the timezone. + */ + private computeIsoWeekBucket(date: Date, timezone: string): string { + // Get the date in the timezone + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + const parts = formatter.formatToParts(date) + const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? "0", 10) + const year = get("year") + const month = get("month") - 1 + const day = get("day") + + // ISO week calculation + const d = new Date(Date.UTC(year, month, day)) + const dayNum = d.getUTCDay() || 7 // Sunday=0 → 7 + d.setUTCDate(d.getUTCDate() + 4 - dayNum) + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)) + const weekNum = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7) + + return `${d.getUTCFullYear()}-W${String(weekNum).padStart(2, "0")}` + } + + // ── Grouping ──────────────────────────────────────────────────────────── + + /** + * Returns the bucket key combinations for the groupBy axes from the event. + * Up to 3 axes can be combined. + */ + private getGroupKeys(item: AggregatableEvent, groupBy: StatsQuery["groupBy"]): Record[] { + if (groupBy.length === 0) { + return [{}] + } + + // Get possible values for each axis as arrays, then compute Cartesian product + const axisValues: Record = {} + + for (const axis of groupBy) { + axisValues[axis] = this.getAxisValues(item, axis) + } + + // Cartesian product + const axes = Object.keys(axisValues) + const results: Record[] = [{}] + + for (const axis of axes) { + const newResults: Record[] = [] + for (const existing of results) { + for (const value of axisValues[axis]) { + newResults.push({ ...existing, [axis]: value }) + } + } + results.length = 0 + results.push(...newResults) + } + + return results + } + + /** + * Returns the values of an event for a single axis. + * The source axis can have multiple values depending on the source of costUsd. + */ + private getAxisValues(item: AggregatableEvent, axis: string): string[] { + const { event } = item + + switch (axis) { + case "day": + return item.dayBucket ? [item.dayBucket] : [] + case "week": + return item.weekBucket ? [item.weekBucket] : [] + case "month": + return item.monthBucket ? [item.monthBucket] : [] + case "provider": + // When an endpoint domain is recorded (custom base URL), append it + // to the provider key so distinct servers appear as separate rows. + // e.g. "openai (kimi.ai)" vs plain "openai" for the default endpoint. + return [event.endpoint ? `${event.provider} (${event.endpoint})` : event.provider] + case "model": + return [event.model] + case "mode": + return [event.mode] + case "status": + return [event.status] + case "source": { + // Separate by the source of costUsd. + // Feature 1: If the event has no costUsd but the cost can be + // computed on-the-fly from model pricing, treat the source as + // "estimated" (since it is derived, not provider-reported). + const sources = new Set() + if (event.usage.costUsd) { + sources.add(event.usage.costUsd.source) + } else { + // Check if cost can be computed; if so, mark as "estimated". + // Otherwise the source remains "unknown". + const computedCost = computeEventCost(event) + if (computedCost > 0) { + sources.add("estimated") + } + } + // Also consider the source of input/output tokens + if (event.usage.inputTokens) { + sources.add(event.usage.inputTokens.source) + } + if (event.usage.outputTokens) { + sources.add(event.usage.outputTokens.source) + } + if (sources.size === 0) { + sources.add("unknown") + } + return Array.from(sources) + } + default: + return [] + } + } + + // ── Accumulation ──────────────────────────────────────────────────────── + + /** + * Accumulates the event's values into the bucket. + * Handles inclusion semantics. + */ + private accumulateIntoBucket(bucket: StatsBucket, event: UsageEventV1): void { + bucket.events++ + + // Status count + switch (event.status) { + case "completed": + bucket.completedCalls++ + break + case "failed": + bucket.failedCalls++ + break + case "cancelled": + bucket.cancelledCalls++ + break + } + + // Token accumulation (inclusion semantics handling) + // If cacheReadInInput is "included", do not subtract cacheReadTokens from inputTokens (already included) + // If "excluded", add separately + // If "unknown", increment unknownEventCount + + const inputTokens = this.extractValue(event.usage.inputTokens) + const outputTokens = this.extractValue(event.usage.outputTokens) + const cacheReadTokens = this.extractValue(event.usage.cacheReadTokens) + const cacheWriteTokens = this.extractValue(event.usage.cacheWriteTokens) + const reasoningTokens = this.extractValue(event.usage.reasoningTokens) + const totalTokens = this.extractValue(event.usage.totalTokens) + // Feature 1: If costUsd is missing on old events, compute it on-the-fly + // from the model's pricing info. Never modifies the stored event. + const costUsd = getEffectiveCost(event) + + // Inclusion semantics check + const hasUnknownInclusion = + event.semantics.cacheReadInInput === "unknown" || + event.semantics.cacheWriteInInput === "unknown" || + event.semantics.reasoningInOutput === "unknown" + + if (hasUnknownInclusion) { + bucket.unknownEventCount++ + } + + // Accumulate token values + // If cacheReadInInput is "included", cacheRead is already included in inputTokens, + // so do not add cacheReadTokens separately (prevent duplication) + // If "excluded", add cacheReadTokens separately + bucket.inputTokens += inputTokens + bucket.outputTokens += outputTokens + + if (event.semantics.cacheReadInInput === "excluded") { + bucket.cacheReadTokens += cacheReadTokens + } else if (event.semantics.cacheReadInInput === "included") { + // Already included in inputTokens, so no separate addition + // But record it in the cacheReadTokens field (for reference) + bucket.cacheReadTokens += cacheReadTokens + } else { + // unknown: add for now, but mark via unknownEventCount + bucket.cacheReadTokens += cacheReadTokens + } + + if (event.semantics.cacheWriteInInput === "excluded") { + bucket.cacheWriteTokens += cacheWriteTokens + } else if (event.semantics.cacheWriteInInput === "included") { + bucket.cacheWriteTokens += cacheWriteTokens + } else { + bucket.cacheWriteTokens += cacheWriteTokens + } + + if (event.semantics.reasoningInOutput === "excluded") { + bucket.reasoningTokens += reasoningTokens + } else if (event.semantics.reasoningInOutput === "included") { + bucket.reasoningTokens += reasoningTokens + } else { + bucket.reasoningTokens += reasoningTokens + } + + // Recompute from input + output (provider-neutral) to repair historical events + // that may have been persisted with the old double-counted sum. + bucket.totalTokens += inputTokens + outputTokens + bucket.costUsd += costUsd + } + + /** + * Extracts the value from a SourcedNumber. + */ + private extractValue(sourced?: SourcedNumber): number { + return sourced?.value ?? 0 + } + + // ── Sorting ──────────────────────────────────────────────────────────── + + /** + * Sorts the buckets. + * - If a time axis (day/week/month) is present, sort by time ascending + * - If only category axes are present, sort by known total descending then name ascending + */ + private sortBuckets(buckets: StatsBucket[], groupBy: StatsQuery["groupBy"]): StatsBucket[] { + const hasTimeAxis = groupBy.some((g) => g === "day" || g === "week" || g === "month") + + if (hasTimeAxis) { + // Sort by time axis + const timeAxis = groupBy.find((g) => g === "day" || g === "week" || g === "month")! + return buckets.sort((a, b) => { + const aTime = a.key[timeAxis] ?? "" + const bTime = b.key[timeAxis] ?? "" + return aTime.localeCompare(bTime) + }) + } + + // Category only: sort by known total descending then name ascending + return buckets.sort((a, b) => { + // Sort by totalTokens descending + const diff = b.totalTokens - a.totalTokens + if (diff !== 0) return diff + + // Sort by name ascending + const aName = Object.values(a.key).join("/") + const bName = Object.values(b.key).join("/") + return aName.localeCompare(bName) + }) + } + + // ── Coverage ──────────────────────────────────────────────────────────── + + /** + * Computes coverage information. + */ + private computeCoverage( + allEvents: UsageEventV1[], + visibleEvents: AggregatableEvent[], + recordingPaused: boolean = false, + ): StatsSnapshot["coverage"] { + const times = visibleEvents.map((e) => new Date(e.event.occurredAt).getTime()).sort((a, b) => a - b) + + const backfilledEventCount = visibleEvents.filter((e) => e.event.provenance === "history-backfill").length + + return { + firstEventAt: times.length > 0 ? new Date(times[0]).toISOString() : undefined, + lastEventAt: times.length > 0 ? new Date(times[times.length - 1]).toISOString() : undefined, + recordingPaused, + backfilledEventCount, + } + } + + // ── Utilities ─────────────────────────────────────────────────────────── + + /** + * Serializes the bucket key object for use as a Map key. + */ + private serializeKey(key: Record): string { + return Object.keys(key) + .sort() + .map((k) => `${k}=${key[k]}`) + .join("|") + } +} diff --git a/src/services/stats/UsageEventStore.ts b/src/services/stats/UsageEventStore.ts new file mode 100644 index 0000000000..234ac11577 --- /dev/null +++ b/src/services/stats/UsageEventStore.ts @@ -0,0 +1,849 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import * as path from "path" +import * as lockfile from "proper-lockfile" + +import type { UsageEventV1 } from "@roo-code/types" +import { UsageEventV1 as UsageEventV1Schema } from "@roo-code/types" + +// ── Constants ────────────────────────────────────────────────────────────── + +/** When a single segment file reaches this size, it rotates to the next segment. */ +const SEGMENT_MAX_BYTES = 5 * 1024 * 1024 // 5 MiB + +/** Hard cap for the total event files. When reached, new writes are suspended. */ +const TOTAL_MAX_BYTES = 100 * 1024 * 1024 // 100 MiB + +/** Segment file name prefix */ +const SEGMENT_PREFIX = "events-" + +/** Segment file extension */ +const SEGMENT_EXT = ".ndjson" + +/** Manifest file name */ +const MANIFEST_FILENAME = "manifest.json" + +/** Quarantine directory name */ +const QUARANTINE_DIRNAME = "quarantine" + +/** Quarantine report file name */ +const QUARANTINE_REPORT_FILENAME = "corrupt-lines.jsonl" + +// ── Error Codes ───────────────────────────────────────────────────────────── + +/** + * Storage error codes. Does not fail the LLM task. + * Format: STATS_STORE/function/NNN + */ +export type StatsStoreErrorCode = + | "STATS_STORE/append/001" // Directory creation failed + | "STATS_STORE/append/002" // Lock acquisition failed + | "STATS_STORE/append/003" // Hard cap reached + | "STATS_STORE/append/004" // File write failed + | "STATS_STORE/append/005" // Manifest update failed + | "STATS_STORE/readAll/001" // Directory read failed + | "STATS_STORE/readAll/002" // Segment file read failed + | "STATS_STORE/clear/001" // Lock acquisition failed + | "STATS_STORE/clear/002" // Manifest replacement failed + | "STATS_STORE/scan/001" // Segment scan failed on restart + +export class StatsStoreError extends Error { + constructor( + public readonly code: StatsStoreErrorCode, + message: string, + public override readonly cause?: unknown, + ) { + super(`[${code}] ${message}`) + this.name = "StatsStoreError" + } +} + +// ── Manifest ──────────────────────────────────────────────────────────────── + +/** + * Storage manifest. Manages generation and the current segment number. + * The cross-process lock is held on this file. + */ +export interface UsageStatsManifest { + /** Manifest schema version */ + manifestVersion: 1 + /** Current generation. Incremented on clear. */ + generation: number + /** Current active segment number (1-based) */ + currentSegment: number + /** Last updated time (ISO 8601 UTC) */ + updatedAt: string +} + +const DEFAULT_MANIFEST: UsageStatsManifest = { + manifestVersion: 1, + generation: 1, + currentSegment: 1, + updatedAt: new Date().toISOString(), +} + +// ── Quarantine Report ─────────────────────────────────────────────────────── + +/** + * Quarantine report entry for a corrupt line. + * Records only the line number and hash, not the original content. + */ +export interface QuarantineReportEntry { + /** Segment file name */ + segment: string + /** 1-based line number */ + line: number + /** SHA-256 hash of the corrupt line content (first 16 chars) */ + hash: string + /** Discovery time (ISO 8601 UTC) */ + at: string +} + +// ── UsageEventStore ───────────────────────────────────────────────────────── + +/** + * NDJSON append-only file based usage event store. + * + * Design principles (architecture report section 5.12-5.14): + * - Uses the `globalStorageUri.fsPath/usage-stats/` directory + * - Manages generation/segment via manifest.json + * - Serializes via in-process promise queue + * - Cross-process uses advisory lock on manifest.json via proper-lockfile + * - 5 MiB segment rotation, 100 MiB hard cap + * - Idempotency: in-memory set + segment scan on restart + * - Corrupt lines are recorded to quarantine and skipped + * - Storage errors are classified with STATS_STORE_* codes, do not fail the LLM task + * + * Security: does not store prompt, response, API key, or workspace path. + * (Structurally guaranteed because these fields are not included in the UsageEventV1 schema) + */ +export class UsageEventStore { + private readonly statsDir: string + private readonly manifestPath: string + private readonly quarantineDir: string + private readonly quarantineReportPath: string + + /** In-process promise queue for serialization */ + private queue: Promise = Promise.resolve() + + /** Idempotency: idempotencyKey set for the current segment */ + private idempotencyKeys: Set = new Set() + + /** Whether initialization is complete */ + private initialized = false + + /** Whether the hard cap has been reached */ + private capped = false + + /** In-memory cached event snapshot. Null when cold. */ + private cachedEvents: UsageEventV1[] | null = null + + /** Generation that the cached snapshot corresponds to. */ + private cachedGeneration = -1 + + /** Segment count that the cached snapshot corresponds to. */ + private cachedSegmentCount = -1 + + /** Single-flight promise for concurrent cold loads. */ + private loadPromise: Promise | null = null + + /** + * @param globalStoragePath VS Code globalStorageUri.fsPath + */ + constructor(globalStoragePath: string) { + this.statsDir = path.join(globalStoragePath, "usage-stats") + this.manifestPath = path.join(this.statsDir, MANIFEST_FILENAME) + this.quarantineDir = path.join(this.statsDir, QUARANTINE_DIRNAME) + this.quarantineReportPath = path.join(this.quarantineDir, QUARANTINE_REPORT_FILENAME) + } + + // ── Public API ────────────────────────────────────────────────────────── + + /** + * Initializes the store. + * Creates directories, loads/creates the manifest, and restores the idempotency set. + * Must be called before the first append. + */ + async initialize(): Promise { + if (this.initialized) { + return + } + + try { + await fs.mkdir(this.statsDir, { recursive: true }) + await fs.mkdir(this.quarantineDir, { recursive: true }) + } catch (err) { + throw new StatsStoreError( + "STATS_STORE/append/001", + `Failed to create stats directory: ${this.statsDir}`, + err, + ) + } + + // Load or create manifest + const manifest = await this.loadOrCreateManifest() + + // Restore idempotency set: scan all segments of the current generation + try { + await this.rebuildIdempotencySet(manifest) + } catch (err) { + // Scan failure is not fatal: dedupe just becomes looser + console.warn(`[UsageEventStore] idempotency scan failed, continuing with empty set:`, err) + } + + // Check hard cap + this.capped = await this.checkTotalSize() + + this.initialized = true + } + + /** + * Appends an event. + * Checks for duplicates within the lock, then appends. + * If the same idempotencyKey already exists, it is ignored (idempotent). + * + * @returns true if appended, false if deduplicated (already exists) + * @throws StatsStoreError Storage error (does not fail the LLM task - caller catches) + */ + async append(event: UsageEventV1): Promise { + // Serialize via in-process promise queue + let resolveFn!: (value: boolean) => void + let rejectFn!: (reason: unknown) => void + const pending = new Promise((resolve, reject) => { + resolveFn = resolve + rejectFn = reject + }) + + this.queue = this.queue.then(async () => { + try { + const result = await this.appendInternal(event) + // Incremental cache update: only after durable success. + if (result && this.cachedEvents) { + // If a segment rotation happened during the append, the cached + // segment count no longer matches the manifest. Invalidate so the + // next readAll rescans from disk instead of returning stale data. + const manifest = await this.loadOrCreateManifest() + if (this.cachedSegmentCount !== manifest.currentSegment) { + this.invalidateCache() + } else { + this.cachedEvents.push(event) + } + } + resolveFn(result) + } catch (err) { + // If durable append failed, we may not know the storage state. + // Invalidate the cache to force a fresh scan on the next read. + if (err instanceof StatsStoreError) { + this.invalidateCache() + } + rejectFn(err) + } + }) + + return pending + } + + /** + * Reads all valid events. + * Corrupt lines are recorded to quarantine and skipped. + * The last unterminated/truncated line is treated as a crash tail and ignored. + */ + async readAll(): Promise { + await this.ensureInitialized() + + const manifest = await this.loadOrCreateManifest() + + // Warm hit: cache matches current generation and the number of segment + // files on disk. Using the on-disk file count (rather than + // manifest.currentSegment) catches external writers that created new + // segments without updating the manifest. + const currentSegmentFiles = await this.listSegmentFiles() + if ( + this.cachedEvents && + this.cachedGeneration === manifest.generation && + this.cachedSegmentCount === currentSegmentFiles.length + ) { + return this.cachedEvents + } + + // Single-flight cold load: concurrent callers share one scan. + if (this.loadPromise) { + return this.loadPromise + } + + this.loadPromise = this.scanAllSegments().then((events) => { + this.cachedEvents = events + this.cachedGeneration = manifest.generation + this.cachedSegmentCount = currentSegmentFiles.length + return events + }) + + try { + return await this.loadPromise + } finally { + this.loadPromise = null + } + } + + /** + * Deletes all statistics data. + * Replaces with a new empty generation. + * On failure, the existing manifest is preserved. + */ + async clear(): Promise { + // Invalidate the cache before mutating so no reader keeps the old + // generation as authoritative. + this.invalidateCache() + + await this.ensureInitialized() + + let releaseLock: (() => Promise) = async () => {} + + try { + releaseLock = await this.acquireManifestLock() + } catch (err) { + throw new StatsStoreError( + "STATS_STORE/clear/001", + "Failed to acquire manifest lock for clear", + err, + ) + } + + try { + const manifest = await this.loadOrCreateManifest() + + // New generation number + const newGeneration = manifest.generation + 1 + const newManifest: UsageStatsManifest = { + ...DEFAULT_MANIFEST, + generation: newGeneration, + currentSegment: 1, + updatedAt: new Date().toISOString(), + } + + // Move existing segment files to a new generation directory (backup) + // Or simply replace with a new manifest and ignore existing files + // Design: "Replace existing segments with a new empty generation" + // Implementation: Move existing segment files under old-generation-{N} + const oldGenDir = path.join(this.statsDir, `old-generation-${manifest.generation}`) + await fs.mkdir(oldGenDir, { recursive: true }) + + const allFiles = await fs.readdir(this.statsDir) + const segmentFiles = allFiles.filter( + (f) => f.startsWith(SEGMENT_PREFIX) && f.endsWith(SEGMENT_EXT), + ) + + for (const file of segmentFiles) { + const oldPath = path.join(this.statsDir, file) + const newPath = path.join(oldGenDir, file) + try { + await fs.rename(oldPath, newPath) + } catch (err) { + // Log move failures and continue + console.warn(`[UsageEventStore] failed to move old segment ${file}:`, err) + } + } + + // Save new manifest (safeWriteJson pattern: temp → rename) + await this.writeManifestAtomic(newManifest) + + // Reset idempotency set + this.idempotencyKeys.clear() + this.capped = false + } catch (err) { + // On failure, preserve the existing manifest (already moved files are not restored - data loss risk) + throw new StatsStoreError( + "STATS_STORE/clear/002", + "Failed to replace manifest during clear", + err, + ) + } finally { + try { + await releaseLock() + } catch (err) { + console.warn(`[UsageEventStore] failed to release manifest lock:`, err) + } + } + } + + /** + * Returns whether the hard cap has been reached. + */ + isCapped(): boolean { + return this.capped + } + + /** + * Returns the current manifest. + */ + async getManifest(): Promise { + await this.ensureInitialized() + return this.loadOrCreateManifest() + } + + // ── Internal: Append ───────────────────────────────────────────────────── + + /** + * Actual append logic. Runs inside the promise queue. + */ + private invalidateCache(): void { + this.cachedEvents = null + this.cachedGeneration = -1 + this.cachedSegmentCount = -1 + this.loadPromise = null + } + + /** + * Scans every segment on disk and returns a single validated event array. + * Corrupt lines are recorded to quarantine and skipped. + */ + private async scanAllSegments(): Promise { + const events: UsageEventV1[] = [] + const quarantineEntries: QuarantineReportEntry[] = [] + + let segmentFiles: string[] + try { + const allFiles = await fs.readdir(this.statsDir) + segmentFiles = allFiles + .filter((f) => f.startsWith(SEGMENT_PREFIX) && f.endsWith(SEGMENT_EXT)) + .sort() + } catch (err) { + throw new StatsStoreError( + "STATS_STORE/readAll/001", + `Failed to read stats directory: ${this.statsDir}`, + err, + ) + } + + // If the number of segments on disk exceeds the current segment count + // recorded in the manifest, an external writer (or a previous process that + // rotated further) produced files we did not observe. Scan all of them and + // let the next readAll re-evaluate cache validity against the freshly + // loaded manifest. + if (segmentFiles.length > this.getSegmentCount()) { + console.warn( + `[UsageEventStore] detected ${segmentFiles.length} segments on disk, manifest only tracks ${this.getSegmentCount()}. Scanning all segments.`, + ) + } + + for (const segmentFile of segmentFiles) { + const segmentPath = path.join(this.statsDir, segmentFile) + let content: string + + try { + content = await fs.readFile(segmentPath, "utf-8") + } catch (err) { + // Skip file read failures (ENOENT etc.) + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + console.warn(`[UsageEventStore] failed to read segment ${segmentFile}:`, err) + } + continue + } + + const lines = content.split("\n") + // Remove the last empty line (trailing newline) + if (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop() + } + + // If the last line is unterminated/truncated, treat it as a crash tail and ignore + // (If the last line is valid JSON, it is parsed; otherwise it goes to quarantine) + for (let i = 0; i < lines.length; i++) { + const lineNum = i + 1 + const line = lines[i] + const isLastLine = i === lines.length - 1 + + if (!line.trim()) { + continue + } + + try { + const parsed = JSON.parse(line) + const result = UsageEventV1Schema.safeParse(parsed) + if (result.success) { + events.push(result.data) + } else { + // zod validation failed: corrupt line + quarantineEntries.push(this.makeQuarantineEntry(segmentFile, lineNum, line)) + // Validation failure of the last line may be a crash tail, so exclude from quarantine + if (isLastLine) { + quarantineEntries.pop() + } + } + } catch { + // JSON parse failed + // Parse failure of the last line is treated as a crash tail and ignored + if (!isLastLine) { + quarantineEntries.push(this.makeQuarantineEntry(segmentFile, lineNum, line)) + } + } + } + } + + // Write quarantine report + if (quarantineEntries.length > 0) { + await this.writeQuarantineReport(quarantineEntries) + } + + return events + } + + /** + * Returns the current segment count from the loaded manifest. + */ + private getSegmentCount(): number { + return this.manifest?.currentSegment ?? 1 + } + + /** + * Lists segment files currently on disk, sorted by name. + */ + private async listSegmentFiles(): Promise { + try { + const allFiles = await fs.readdir(this.statsDir) + return allFiles + .filter((f) => f.startsWith(SEGMENT_PREFIX) && f.endsWith(SEGMENT_EXT)) + .sort() + } catch { + return [] + } + } + + private async appendInternal(event: UsageEventV1): Promise { + await this.ensureInitialized() + + // Check hard cap + if (this.capped) { + throw new StatsStoreError( + "STATS_STORE/append/003", + "Storage hard cap (100 MiB) reached, new events suspended", + ) + } + + // Idempotency check + if (this.idempotencyKeys.has(event.idempotencyKey)) { + return false + } + + let releaseLock: (() => Promise) = async () => {} + + try { + releaseLock = await this.acquireManifestLock() + } catch (err) { + throw new StatsStoreError( + "STATS_STORE/append/002", + "Failed to acquire manifest lock for append", + err, + ) + } + + try { + const manifest = await this.loadOrCreateManifest() + const segmentPath = this.getSegmentPath(manifest.currentSegment) + + // Check if the segment file exists and its size + let segmentSize = 0 + try { + const stat = await fs.stat(segmentPath) + segmentSize = stat.size + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err + } + // If the file does not exist, create a new one + } + + // Check segment rotation + if (segmentSize >= SEGMENT_MAX_BYTES) { + manifest.currentSegment += 1 + manifest.updatedAt = new Date().toISOString() + await this.writeManifestAtomic(manifest) + } + + // B3 fix: Recalculate segmentPath based on currentSegment after rotation. + // Previously, the pre-rotation old segmentPath was used as-is, causing appends to + // continue going to the old segment, invalidating the 5MiB rotation design and + // allowing a single segment to grow indefinitely. + const activeSegmentPath = this.getSegmentPath(manifest.currentSegment) + + // Append event as compact JSON + \n + const line = JSON.stringify(event) + "\n" + + try { + // Open in append mode and write + const handle = await fs.open(activeSegmentPath, "a") + try { + await handle.writeFile(line, "utf-8") + // Return success after syncing the file handle + await handle.sync() + } finally { + await handle.close() + } + } catch (err) { + throw new StatsStoreError( + "STATS_STORE/append/004", + `Failed to write event to segment ${manifest.currentSegment}`, + err, + ) + } + + // Add to idempotency set + this.idempotencyKeys.add(event.idempotencyKey) + + // Check total size and update cap + this.capped = await this.checkTotalSize() + + return true + } finally { + try { + await releaseLock() + } catch (err) { + console.warn(`[UsageEventStore] failed to release manifest lock:`, err) + } + } + } + + // ── Internal: Manifest ────────────────────────────────────────────────── + + private manifest: UsageStatsManifest | null = null + + /** + * Loads the manifest or creates it with default values. + */ + private async loadOrCreateManifest(): Promise { + if (this.manifest) { + return this.manifest + } + + try { + const content = await fs.readFile(this.manifestPath, "utf-8") + const parsed = JSON.parse(content) + // Basic field validation + if ( + typeof parsed.manifestVersion === "number" && + typeof parsed.generation === "number" && + typeof parsed.currentSegment === "number" + ) { + this.manifest = parsed as UsageStatsManifest + return this.manifest + } + // On validation failure, overwrite with default values + const defaultManifest = { ...DEFAULT_MANIFEST, updatedAt: new Date().toISOString() } + await this.writeManifestAtomic(defaultManifest) + return defaultManifest + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + // If manifest does not exist, create it + const defaultManifest = { ...DEFAULT_MANIFEST, updatedAt: new Date().toISOString() } + await this.writeManifestAtomic(defaultManifest) + return defaultManifest + } + // Other errors return default values + console.warn(`[UsageEventStore] failed to load manifest, using default:`, err) + const defaultManifest = { ...DEFAULT_MANIFEST, updatedAt: new Date().toISOString() } + this.manifest = defaultManifest + return defaultManifest + } + } + + /** + * Stores the manifest atomically (temp → rename pattern). + */ + private async writeManifestAtomic(manifest: UsageStatsManifest): Promise { + this.manifest = manifest + const tempPath = `${this.manifestPath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}` + const content = JSON.stringify(manifest, null, "\t") + + try { + await fs.writeFile(tempPath, content, "utf-8") + await fs.rename(tempPath, this.manifestPath) + } catch (err) { + // Clean up temp file + try { + await fs.unlink(tempPath) + } catch { + // ignore + } + throw new StatsStoreError( + "STATS_STORE/append/005", + "Failed to write manifest atomically", + err, + ) + } + } + + // ── Internal: Lock ─────────────────────────────────────────────────────── + + /** + * Acquires a cross-process advisory lock on manifest.json. + */ + private async acquireManifestLock(): Promise<() => Promise> { + // Create manifest file if it does not exist (lockfile.lock may require a file) + try { + await fs.access(this.manifestPath) + } catch { + await this.writeManifestAtomic({ ...DEFAULT_MANIFEST, updatedAt: new Date().toISOString() }) + } + + return lockfile.lock(this.manifestPath, { + stale: 31000, + update: 10000, + realpath: false, + retries: { + retries: 5, + factor: 2, + minTimeout: 100, + maxTimeout: 1000, + }, + onCompromised: (err) => { + console.error(`[UsageEventStore] manifest lock was compromised:`, err) + throw err + }, + }) + } + + // ── Internal: Idempotency ──────────────────────────────────────────────── + + /** + * Scans idempotencyKeys from all segments of the current generation to restore the set. + */ + private async rebuildIdempotencySet(manifest: UsageStatsManifest): Promise { + this.idempotencyKeys.clear() + + for (let seg = 1; seg <= manifest.currentSegment; seg++) { + const segmentPath = this.getSegmentPath(seg) + + let content: string + try { + content = await fs.readFile(segmentPath, "utf-8") + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + continue + } + throw new StatsStoreError( + "STATS_STORE/scan/001", + `Failed to scan segment ${seg} for idempotency rebuild`, + err, + ) + } + + const lines = content.split("\n") + for (const line of lines) { + if (!line.trim()) continue + try { + const parsed = JSON.parse(line) + if (parsed && typeof parsed.idempotencyKey === "string") { + this.idempotencyKeys.add(parsed.idempotencyKey) + } + } catch { + // Skip corrupt lines during scan + } + } + } + } + + // ── Internal: Size Management ──────────────────────────────────────────── + + /** + * Checks the total event file size and returns whether the hard cap has been reached. + */ + private async checkTotalSize(): Promise { + try { + const allFiles = await fs.readdir(this.statsDir) + const segmentFiles = allFiles.filter( + (f) => f.startsWith(SEGMENT_PREFIX) && f.endsWith(SEGMENT_EXT), + ) + + let totalSize = 0 + for (const file of segmentFiles) { + try { + const stat = await fs.stat(path.join(this.statsDir, file)) + totalSize += stat.size + } catch { + // skip + } + } + + return totalSize >= TOTAL_MAX_BYTES + } catch { + return false + } + } + + // ── Internal: Quarantine ──────────────────────────────────────────────── + + /** + * Creates a quarantine entry for a corrupt line. + * Records only the line number and hash, not the original content. + */ + private makeQuarantineEntry(segment: string, line: number, content: string): QuarantineReportEntry { + // Simple hash (without crypto, content-based) + // In a production environment, crypto.createHash could be used, + // but here a simple hash is used to minimize dependencies. + let hash = 0 + for (let i = 0; i < content.length; i++) { + const char = content.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Keep as 32-bit integer + } + const hashHex = (hash >>> 0).toString(16).padStart(8, "0") + + return { + segment, + line, + hash: hashHex, + at: new Date().toISOString(), + } + } + + /** + * Writes the quarantine report in append mode. + */ + private async writeQuarantineReport(entries: QuarantineReportEntry[]): Promise { + try { + const lines = entries.map((e) => JSON.stringify(e)).join("\n") + "\n" + const handle = await fs.open(this.quarantineReportPath, "a") + try { + await handle.writeFile(lines, "utf-8") + } finally { + await handle.close() + } + } catch (err) { + // Quarantine write failure is not fatal + console.warn(`[UsageEventStore] failed to write quarantine report:`, err) + } + } + + // ── Internal: Utilities ────────────────────────────────────────────────── + + /** + * Generates a file path from a segment number. + */ + private getSegmentPath(segmentNumber: number): string { + const padded = String(segmentNumber).padStart(6, "0") + return path.join(this.statsDir, `${SEGMENT_PREFIX}${padded}${SEGMENT_EXT}`) + } + + /** + * Checks whether initialization is complete; if not, initializes. + */ + private async ensureInitialized(): Promise { + if (!this.initialized) { + await this.initialize() + } + } + + /** + * For testing: returns the size of the idempotency set + */ + _getIdempotencyKeyCount(): number { + return this.idempotencyKeys.size + } + + /** + * For testing: returns the stats directory path + */ + _getStatsDir(): string { + return this.statsDir + } +} diff --git a/src/services/stats/UsageRecorder.ts b/src/services/stats/UsageRecorder.ts new file mode 100644 index 0000000000..8a2f09661d --- /dev/null +++ b/src/services/stats/UsageRecorder.ts @@ -0,0 +1,156 @@ +// src/services/stats/UsageRecorder.ts +// +// Commit 3: Final usage measurement for API attempts. +// No per-chunk recording; records only at terminal finalize. +// Store errors are isolated with try-catch so they do not affect existing task results. + +import * as crypto from "crypto" + +import type { UsageEventV1, UsageValueSource, InclusionRule } from "@roo-code/types" + +// ── Types ─────────────────────────────────────────────────────────────────── + +/** + * Narrow append dependency used by UsageRecorder. + * Allows a UsageEventStore or a service-owned facade to be injected. + */ +export interface UsageEventSink { + append(event: UsageEventV1): Promise +} + +/** + * Context required for UsageRecorder to create an event at terminal finalize. + * Passed at the point in the task lifecycle where the API call completed/failed/was cancelled. + */ +export interface UsageRecordingContext { + taskId: string + parentTaskId?: string + provider: string + model: string + mode: string + attempt: number + // accumulated usage from stream + inputTokens: number + outputTokens: number + cacheWriteTokens?: number + cacheReadTokens?: number + reasoningTokens?: number + totalCost?: number + // semantics + cacheReadInInput: InclusionRule + cacheWriteInInput: InclusionRule + reasoningInOutput: InclusionRule + // source + costSource: UsageValueSource + tokenSource: UsageValueSource + /** + * Domain extracted from the provider's custom base URL. Only set when the + * user configured a custom base URL that differs from the provider default. + * Absent for default endpoints. See resolveEndpoint() in Task.ts. + */ + endpoint?: string +} + +// ── UsageRecorder ──────────────────────────────────────────────────────────── + +/** + * Records usage events at the terminal finalize boundary of an API attempt. + * + * Design principles (architecture report section 5.5-5.8): + * - Does not record events per chunk. Records only at terminal finalize. + * - Records at most once for the same requestKey + status combination (idempotency). + * - Store errors do not affect existing task results (best-effort). + * + * Hexagonal boundary: The task lifecycle knows only the UsageRecorder interface + * and is unaware of the file implementation details (UsageEventStore). + */ +export class UsageRecorder { + private readonly sink: UsageEventSink + private readonly finalizedKeys: Set = new Set() + + constructor(sink: UsageEventSink) { + this.sink = sink + } + + /** + * Called at the terminal finalize of an API attempt. + * + * @param requestKey Request identifier (taskId:apiReqIndex:attempt format — B1 fix: + * includes apiReqIndex so multiple tool-use turns of one task get different keys) + * @param status "completed" | "failed" | "cancelled" + * @param ctx Usage recording context + * + * Records at most once for the same requestKey:status combination. + * Silently ignores store errors (no impact on task). + */ + async finalizeUsageEvent( + requestKey: string, + status: "completed" | "failed" | "cancelled", + ctx: UsageRecordingContext, + ): Promise { + // terminal finalize: idempotency check + const idempotencyKey = `${requestKey}:${status}` + if (this.finalizedKeys.has(idempotencyKey)) { + return + } + this.finalizedKeys.add(idempotencyKey) + + const event: UsageEventV1 = { + schemaVersion: 1, + eventId: crypto.randomUUID(), + idempotencyKey, + occurredAt: new Date().toISOString(), + timezoneOffsetMinutes: new Date().getTimezoneOffset(), + status, + attempt: ctx.attempt, + taskId: ctx.taskId, + parentTaskId: ctx.parentTaskId, + provider: ctx.provider, + model: ctx.model, + mode: ctx.mode, + endpoint: ctx.endpoint, + usage: { + inputTokens: ctx.inputTokens > 0 ? { value: ctx.inputTokens, source: ctx.tokenSource } : undefined, + outputTokens: ctx.outputTokens > 0 ? { value: ctx.outputTokens, source: ctx.tokenSource } : undefined, + cacheWriteTokens: ctx.cacheWriteTokens + ? { value: ctx.cacheWriteTokens, source: ctx.tokenSource } + : undefined, + cacheReadTokens: ctx.cacheReadTokens + ? { value: ctx.cacheReadTokens, source: ctx.tokenSource } + : undefined, + reasoningTokens: ctx.reasoningTokens + ? { value: ctx.reasoningTokens, source: ctx.tokenSource } + : undefined, + // totalTokens = inputTokens + outputTokens (provider-neutral definition). + // Cache tokens are a subset/breakdown of input; reasoning tokens are a subset of output. + // Adding them separately would double-count. See docs/260720_22_gitignore-heatmap-fix/213200_debug-report.md + totalTokens: { + value: ctx.inputTokens + ctx.outputTokens, + source: ctx.tokenSource, + }, + costUsd: ctx.totalCost ? { value: ctx.totalCost, source: ctx.costSource } : undefined, + }, + semantics: { + cacheReadInInput: ctx.cacheReadInInput, + cacheWriteInInput: ctx.cacheWriteInInput, + reasoningInOutput: ctx.reasoningInOutput, + }, + provenance: "live", + } + + try { + await this.sink.append(event) + } catch { + // store error must not break task + // STATS_STORE/append/* errors are classified inside UsageEventStore + } + } + + /** + * For testing/verification: returns the current state of the finalizedKeys set. + * Not used in production code. + */ + _hasFinalized(requestKey: string, status: string): boolean { + return this.finalizedKeys.has(`${requestKey}:${status}`) + } +} diff --git a/src/services/stats/UsageStatsService.ts b/src/services/stats/UsageStatsService.ts new file mode 100644 index 0000000000..8d035360e4 --- /dev/null +++ b/src/services/stats/UsageStatsService.ts @@ -0,0 +1,549 @@ +import type { UsageEventV1, StatsQuery, StatsSnapshot } from "@roo-code/types" + +import { UsageEventStore, StatsStoreError } from "./UsageEventStore" +import { UsageAggregator } from "./UsageAggregator" + +// ── Export Format ─────────────────────────────────────────────────────────── + +export type ExportFormat = "json" | "csv" + +/** JSON export result */ +export interface JsonExport { + exportSchemaVersion: 1 + exportedAt: string + query: StatsQuery + events: UsageEventV1[] +} + +// ── Error Codes ───────────────────────────────────────────────────────────── + +export type StatsServiceErrorCode = + | "STATS_SERVICE/export/001" // Unsupported format + | "STATS_SERVICE/clear/001" // Nonce mismatch + | "STATS_SERVICE/backfill/001" // Backfill failed + +export class StatsServiceError extends Error { + constructor( + public readonly code: StatsServiceErrorCode, + message: string, + public override readonly cause?: unknown, + ) { + super(`[${code}] ${message}`) + this.name = "StatsServiceError" + } +} + +// ── CSV Column Order ──────────────────────────────────────────────────────── + +/** + * Fixed column order for CSV export. + * Missing values become empty cells, 0 becomes `0`. + * Source and inclusion fields are placed in separate columns. + */ +const CSV_COLUMNS = [ + "eventId", + "idempotencyKey", + "occurredAt", + "timezoneOffsetMinutes", + "status", + "attempt", + "taskId", + "parentTaskId", + "provider", + "model", + "mode", + "inputTokens", + "inputTokensSource", + "outputTokens", + "outputTokensSource", + "cacheWriteTokens", + "cacheWriteTokensSource", + "cacheReadTokens", + "cacheReadTokensSource", + "reasoningTokens", + "reasoningTokensSource", + "totalTokens", + "totalTokensSource", + "costUsd", + "costUsdSource", + "cacheReadInInput", + "cacheWriteInInput", + "reasoningInOutput", + "provenance", +] as const + +// ── UsageStatsService ─────────────────────────────────────────────────────── + +/** + * Statistics service facade. + * Integrates UsageEventStore and UsageAggregator. + * + * Design principles (architecture report section 5.15-5.17): + * - query: Query statistics via the aggregation engine + * - export: Export statistics in JSON/CSV format + * - clear: Delete statistics data after nonce verification + * - backfill: Restore events from past task history + * + * Security: does not store prompt, response, API key, or workspace path. + */ +export class UsageStatsService { + private readonly store: UsageEventStore + private readonly aggregator: UsageAggregator + + /** Nonce for clear verification (short-lived) */ + private clearNonce: string | null = null + private clearNonceExpiresAt: number = 0 + + constructor(globalStoragePath: string) { + this.store = new UsageEventStore(globalStoragePath) + this.aggregator = new UsageAggregator() + } + + // ── Public API ────────────────────────────────────────────────────────── + + /** + * Initializes the service. + * Performs store initialization. + */ + async initialize(): Promise { + await this.store.initialize() + } + + /** + * Appends a usage event to the shared store. + * This is the single in-process write entry for live recordings. + * Delegates to the owned UsageEventStore. + * + * @returns true if appended, false if deduplicated + */ + append(event: UsageEventV1): Promise { + return this.store.append(event) + } + + /** + * Queries statistics. + * + * @param query Statistics query + * @param options Additional options + * @returns Statistics snapshot + */ + async queryStats( + query: StatsQuery, + options: { recordingPaused?: boolean } = {}, + ): Promise { + const events = await this.store.readAll() + return this.aggregator.query(events, query, options) + } + + /** + * Exports statistics. + * + * @param query Statistics query (export target range) + * @param format Export format ("json" or "csv") + * @returns Object for JSON, string for CSV + */ + async exportStats( + query: StatsQuery, + format: ExportFormat, + ): Promise { + const events = await this.store.readAll() + + // Time range filtering + const filtered = this.filterEventsByQuery(events, query) + + switch (format) { + case "json": + return { + exportSchemaVersion: 1, + exportedAt: new Date().toISOString(), + query, + events: filtered, + } + + case "csv": + return this.eventsToCsv(filtered) + + default: + throw new StatsServiceError( + "STATS_SERVICE/export/001", + `Unsupported export format: ${format as string}`, + ) + } + } + + /** + * Returns the raw events filtered by the query's time range and + * includeCancelled flag. This avoids the JSON serialize/parse round-trip + * that `exportStats(query, "json")` performs for callers that only need + * in-memory events (e.g., dashboard session grouping). + * + * @param query Statistics query + * @returns Filtered events + */ + async getFilteredEvents(query: StatsQuery): Promise { + const events = await this.store.readAll() + return this.filterEventsByQuery(events, query) + } + + /** + * Issues a nonce for statistics deletion. + * The Host calls this method after the UI's first confirmation dialog. + * + * @returns Short-lived nonce (valid for 5 minutes) + */ + issueClearNonce(): string { + const nonce = this.generateNonce() + this.clearNonce = nonce + // Valid for 5 minutes + this.clearNonceExpiresAt = Date.now() + 5 * 60 * 1000 + return nonce + } + + /** + * Deletes statistics data. + * The nonce must be valid (within 5 minutes, single-use). + * + * @param nonce Nonce issued by issueClearNonce() + * @throws StatsServiceError on nonce mismatch or expiration + */ + async clearStats(nonce: string): Promise { + // Nonce verification + if (!this.clearNonce || this.clearNonce !== nonce) { + throw new StatsServiceError( + "STATS_SERVICE/clear/001", + "Invalid clear nonce: nonce mismatch", + ) + } + + if (Date.now() > this.clearNonceExpiresAt) { + this.clearNonce = null + throw new StatsServiceError( + "STATS_SERVICE/clear/001", + "Invalid clear nonce: nonce expired", + ) + } + + // Consume single-use nonce + this.clearNonce = null + + // Clear the store + await this.store.clear() + } + + /** + * Restores usage events from past task history. + * Called when UsageRecorder in Commit 3 is actually implemented. + * + * @param events Array of events to restore + * @returns Number of restored events (actual appended count may differ due to dedupe) + */ + async backfillFromHistory(events: UsageEventV1[]): Promise { + let appended = 0 + + for (const event of events) { + try { + // provenance must be "history-backfill" + const backfillEvent: UsageEventV1 = { + ...event, + provenance: "history-backfill", + } + const result = await this.store.append(backfillEvent) + if (result) { + appended++ + } + } catch (err) { + // Storage errors do not fail the LLM task + if (err instanceof StatsStoreError) { + console.warn(`[UsageStatsService] backfill append failed for event ${event.eventId}:`, err) + } else { + throw new StatsServiceError( + "STATS_SERVICE/backfill/001", + `Backfill failed for event ${event.eventId}`, + err, + ) + } + } + } + + return appended + } + + /** + * Checks whether the store has reached the hard cap. + */ + isCapped(): boolean { + return this.store.isCapped() + } + + // ── Internal: Event Filtering ─────────────────────────────────────────── + + /** + * Filters events according to the query conditions. + * Handles time range and includeCancelled. + */ + private filterEventsByQuery(events: UsageEventV1[], query: StatsQuery): UsageEventV1[] { + // Time range + let from: Date | undefined + let to: Date | undefined + + if (query.preset) { + const now = new Date() + const range = this.resolvePresetRange(query.preset, query.timezone, now) + from = range.from + to = range.to + } else { + from = query.from ? new Date(query.from) : undefined + to = query.to ? new Date(query.to) : undefined + } + + let filtered = events.filter((event) => { + const eventTime = new Date(event.occurredAt).getTime() + if (from && eventTime < from.getTime()) return false + if (to && eventTime >= to.getTime()) return false + return true + }) + + // Cancelled filtering + const includeCancelled = query.includeCancelled ?? false + if (!includeCancelled) { + filtered = filtered.filter((e) => e.status !== "cancelled") + } + + return filtered + } + + /** + * Computes the time range from a preset. + */ + private resolvePresetRange( + preset: NonNullable, + timezone: string, + now: Date, + ): { from?: Date; to?: Date } { + const tzNow = this.toTimezoneStartOfDay(now, timezone) + + switch (preset) { + case "today": { + const from = new Date(tzNow) + const to = new Date(from) + to.setDate(to.getDate() + 1) + return { from, to } + } + case "7d": { + const to = new Date(tzNow) + to.setDate(to.getDate() + 1) + const from = new Date(to) + from.setDate(from.getDate() - 7) + return { from, to } + } + case "30d": { + const to = new Date(tzNow) + to.setDate(to.getDate() + 1) + const from = new Date(to) + from.setDate(from.getDate() - 30) + return { from, to } + } + case "all": + return {} + } + } + + /** + * Returns the 00:00:00 UTC for the given date based on the timezone. + */ + private toTimezoneStartOfDay(date: Date, timezone: string): Date { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + const parts = formatter.formatToParts(date) + const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? "0", 10) + const year = get("year") + const month = get("month") - 1 + const day = get("day") + + // Convert timezone wall-clock midnight to UTC + const midnightEpoch = Date.UTC(year, month, day, 0, 0, 0) + const tzOffset = this.getTimezoneOffsetMinutes(date, timezone) + // tzOffset = UTC - (timezone wall-clock as UTC) + // Actual UTC of timezone midnight = timezone midnight wall-clock as UTC + tzOffset + return new Date(midnightEpoch + tzOffset * 60 * 1000) + } + + /** + * Returns the UTC offset for the specified timezone in minutes. + */ + private getTimezoneOffsetMinutes(date: Date, timezone: string): number { + const utcDate = new Date(date.toISOString()) + const tzFormatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }) + const tzParts = tzFormatter.formatToParts(utcDate) + const get = (type: string) => parseInt(tzParts.find((p) => p.type === type)?.value ?? "0", 10) + const tzYear = get("year") + const tzMonth = get("month") - 1 + const tzDay = get("day") + const tzHour = get("hour") % 24 + const tzMinute = get("minute") + const tzSecond = get("second") + + const tzEpoch = Date.UTC(tzYear, tzMonth, tzDay, tzHour, tzMinute, tzSecond) + return Math.round((utcDate.getTime() - tzEpoch) / 60000) + } + + // ── Internal: CSV ──────────────────────────────────────────────────────── + + /** + * Converts an array of events to a CSV string. + * - One row per event + * - Fixed column order + * - Missing values become empty cells, 0 becomes `0` + * - Source and inclusion fields are placed in separate columns + * - Prevents spreadsheet formula injection: prefixes `=`, `+`, `-`, `@` with `'` + */ + private eventsToCsv(events: UsageEventV1[]): string { + const rows: string[] = [] + + // header + rows.push(CSV_COLUMNS.join(",")) + + for (const event of events) { + const row = this.eventToCsvRow(event) + rows.push(row) + } + + return rows.join("\n") + } + + /** + * Converts a single event to a CSV row. + */ + private eventToCsvRow(event: UsageEventV1): string { + const values: string[] = [] + + for (const col of CSV_COLUMNS) { + const value = this.extractCsvValue(event, col) + values.push(this.escapeCsvCell(value)) + } + + return values.join(",") + } + + /** + * Extracts the value corresponding to a column from an event. + */ + private extractCsvValue(event: UsageEventV1, column: string): string { + switch (column) { + case "eventId": + return event.eventId + case "idempotencyKey": + return event.idempotencyKey + case "occurredAt": + return event.occurredAt + case "timezoneOffsetMinutes": + return String(event.timezoneOffsetMinutes) + case "status": + return event.status + case "attempt": + return String(event.attempt) + case "taskId": + return event.taskId + case "parentTaskId": + return event.parentTaskId ?? "" + case "provider": + return event.provider + case "model": + return event.model + case "mode": + return event.mode + case "inputTokens": + return event.usage.inputTokens ? String(event.usage.inputTokens.value) : "" + case "inputTokensSource": + return event.usage.inputTokens?.source ?? "" + case "outputTokens": + return event.usage.outputTokens ? String(event.usage.outputTokens.value) : "" + case "outputTokensSource": + return event.usage.outputTokens?.source ?? "" + case "cacheWriteTokens": + return event.usage.cacheWriteTokens ? String(event.usage.cacheWriteTokens.value) : "" + case "cacheWriteTokensSource": + return event.usage.cacheWriteTokens?.source ?? "" + case "cacheReadTokens": + return event.usage.cacheReadTokens ? String(event.usage.cacheReadTokens.value) : "" + case "cacheReadTokensSource": + return event.usage.cacheReadTokens?.source ?? "" + case "reasoningTokens": + return event.usage.reasoningTokens ? String(event.usage.reasoningTokens.value) : "" + case "reasoningTokensSource": + return event.usage.reasoningTokens?.source ?? "" + case "totalTokens": + return event.usage.totalTokens ? String(event.usage.totalTokens.value) : "" + case "totalTokensSource": + return event.usage.totalTokens?.source ?? "" + case "costUsd": + return event.usage.costUsd ? String(event.usage.costUsd.value) : "" + case "costUsdSource": + return event.usage.costUsd?.source ?? "" + case "cacheReadInInput": + return event.semantics.cacheReadInInput + case "cacheWriteInInput": + return event.semantics.cacheWriteInInput + case "reasoningInOutput": + return event.semantics.reasoningInOutput + case "provenance": + return event.provenance + default: + return "" + } + } + + /** + * Escapes a CSV cell. + * - Prevents spreadsheet formula injection: prefixes `=`, `+`, `-`, `@` with `'` + * - If the value contains `,`, `"`, or `\n`, wraps it in `"..."` and escapes inner `"` as `""` + */ + private escapeCsvCell(value: string): string { + // Empty value becomes an empty cell + if (value === "") { + return "" + } + + // Prevent formula injection + let escaped = value + if (/^[=+\-@]/.test(escaped)) { + escaped = `'${escaped}` + } + + // Check if quoting is needed + if (/[",\n]/.test(escaped)) { + escaped = `"${escaped.replace(/"/g, '""')}"` + } + + return escaped + } + + // ── Internal: Nonce ───────────────────────────────────────────────────── + + /** + * Generates a short-lived nonce. + * Provides a fallback for environments where crypto.randomUUID is unavailable. + */ + private generateNonce(): string { + try { + const crypto = require("crypto") + return crypto.randomUUID() + } catch { + // fallback: timestamp + random + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + } + } +} diff --git a/src/services/stats/__tests__/UsageAggregator.spec.ts b/src/services/stats/__tests__/UsageAggregator.spec.ts new file mode 100644 index 0000000000..dd1c658c29 --- /dev/null +++ b/src/services/stats/__tests__/UsageAggregator.spec.ts @@ -0,0 +1,1065 @@ +import { describe, it, expect } from "vitest" + +import type { UsageEventV1, StatsQuery, StatsSnapshot } from "@roo-code/types" + +import { UsageAggregator } from "../UsageAggregator" + +// ── Test Helpers ──────────────────────────────────────────────────────────── + +/** + * Creates a UsageEventV1 event for testing. + */ +function makeEvent(overrides: Partial = {}): UsageEventV1 { + return { + schemaVersion: 1, + eventId: `evt-${Math.random().toString(36).slice(2)}`, + idempotencyKey: `idem-${Math.random().toString(36).slice(2)}`, + occurredAt: "2026-07-19T10:00:00.000Z", + timezoneOffsetMinutes: 540, // KST UTC+9 + status: "completed", + attempt: 1, + taskId: "task-001", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + mode: "code", + usage: { + inputTokens: { value: 1000, source: "provider" }, + outputTokens: { value: 500, source: "provider" }, + costUsd: { value: 0.01, source: "provider" }, + }, + semantics: { + cacheReadInInput: "excluded", + cacheWriteInInput: "excluded", + reasoningInOutput: "excluded", + }, + provenance: "live", + ...overrides, + } +} + +/** + * Creates a default StatsQuery. + */ +function makeQuery(overrides: Partial = {}): StatsQuery { + return { + timezone: "Asia/Seoul", + groupBy: ["day"], + includeCancelled: false, + ...overrides, + } +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("UsageAggregator", () => { + const aggregator = new UsageAggregator() + + describe("query - basic", () => { + it("should return empty snapshot for no events", () => { + const query = makeQuery() + const result = aggregator.query([], query) + + expect(result.buckets).toHaveLength(0) + expect(result.totals.events).toBe(0) + expect(result.totals.completedCalls).toBe(0) + expect(result.coverage.firstEventAt).toBeUndefined() + expect(result.coverage.lastEventAt).toBeUndefined() + expect(result.coverage.recordingPaused).toBe(false) + expect(result.coverage.backfilledEventCount).toBe(0) + }) + + it("should aggregate a single event into totals", () => { + const event = makeEvent({ + usage: { + inputTokens: { value: 1000, source: "provider" }, + outputTokens: { value: 500, source: "provider" }, + costUsd: { value: 0.01, source: "provider" }, + }, + }) + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query([event], query) + + expect(result.totals.events).toBe(1) + expect(result.totals.completedCalls).toBe(1) + expect(result.totals.inputTokens).toBe(1000) + expect(result.totals.outputTokens).toBe(500) + expect(result.totals.costUsd).toBe(0.01) + }) + + it("should aggregate multiple events into totals", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + usage: { + inputTokens: { value: 1000, source: "provider" }, + outputTokens: { value: 500, source: "provider" }, + }, + }), + makeEvent({ + eventId: "evt-2", + idempotencyKey: "idem-2", + usage: { + inputTokens: { value: 2000, source: "provider" }, + outputTokens: { value: 1000, source: "provider" }, + }, + }), + makeEvent({ + eventId: "evt-3", + idempotencyKey: "idem-3", + usage: { + inputTokens: { value: 3000, source: "provider" }, + outputTokens: { value: 1500, source: "provider" }, + }, + }), + ] + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.totals.events).toBe(3) + expect(result.totals.inputTokens).toBe(6000) + expect(result.totals.outputTokens).toBe(3000) + }) + }) + + describe("query - status grouping", () => { + it("should count completed, failed, and cancelled separately", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", status: "completed" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", status: "completed" }), + makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3", status: "failed" }), + makeEvent({ eventId: "evt-4", idempotencyKey: "idem-4", status: "cancelled" }), + ] + const query = makeQuery({ groupBy: [], includeCancelled: true }) + + const result = aggregator.query(events, query) + + expect(result.totals.events).toBe(4) + expect(result.totals.completedCalls).toBe(2) + expect(result.totals.failedCalls).toBe(1) + expect(result.totals.cancelledCalls).toBe(1) + }) + + it("should exclude cancelled events when includeCancelled is false", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", status: "completed" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", status: "cancelled" }), + ] + const query = makeQuery({ groupBy: [], includeCancelled: false }) + + const result = aggregator.query(events, query) + + expect(result.totals.events).toBe(1) + expect(result.totals.completedCalls).toBe(1) + expect(result.totals.cancelledCalls).toBe(0) + }) + }) + + describe("query - day grouping", () => { + it("should group events by day bucket", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", occurredAt: "2026-07-19T10:00:00.000Z" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", occurredAt: "2026-07-19T15:00:00.000Z" }), + makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3", occurredAt: "2026-07-20T10:00:00.000Z" }), + ] + const query = makeQuery({ groupBy: ["day"] }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(2) + // Based on Asia/Seoul (UTC+9), 2026-07-19 10:00 UTC = 2026-07-19 19:00 KST + // 2026-07-20 10:00 UTC = 2026-07-20 19:00 KST + const dayKeys = result.buckets.map((b) => b.key.day).sort() + expect(dayKeys).toContain("2026-07-19") + expect(dayKeys).toContain("2026-07-20") + }) + + it("should sort day buckets in ascending order", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", occurredAt: "2026-07-20T10:00:00.000Z" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", occurredAt: "2026-07-19T10:00:00.000Z" }), + ] + const query = makeQuery({ groupBy: ["day"] }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(2) + expect(result.buckets[0].key.day).toBe("2026-07-19") + expect(result.buckets[1].key.day).toBe("2026-07-20") + }) + }) + + describe("query - provider/model/mode grouping", () => { + it("should group by provider", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", provider: "anthropic" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", provider: "anthropic" }), + makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3", provider: "openai" }), + ] + const query = makeQuery({ groupBy: ["provider"] }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(2) + const providers = result.buckets.map((b) => b.key.provider).sort() + expect(providers).toEqual(["anthropic", "openai"]) + }) + + it("should separate provider buckets by endpoint domain", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", provider: "openai", endpoint: "kimi.ai" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", provider: "openai", endpoint: "kimi.ai" }), + makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3", provider: "openai" }), // default endpoint + makeEvent({ eventId: "evt-4", idempotencyKey: "idem-4", provider: "openai", endpoint: "localhost:1234" }), + ] + const query = makeQuery({ groupBy: ["provider"] }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(3) + const keys = result.buckets.map((b) => b.key.provider).sort() + expect(keys).toEqual(["openai", "openai (kimi.ai)", "openai (localhost:1234)"]) + }) + + it("should group by model", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", model: "claude-sonnet-4-20250514" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", model: "gpt-4o" }), + ] + const query = makeQuery({ groupBy: ["model"] }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(2) + }) + + it("should group by mode", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", mode: "code" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", mode: "architect" }), + ] + const query = makeQuery({ groupBy: ["mode"] }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(2) + }) + }) + + describe("query - multi-axis grouping", () => { + it("should group by day + provider (2 axes)", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + occurredAt: "2026-07-19T10:00:00.000Z", + provider: "anthropic", + }), + makeEvent({ + eventId: "evt-2", + idempotencyKey: "idem-2", + occurredAt: "2026-07-19T10:00:00.000Z", + provider: "openai", + }), + makeEvent({ + eventId: "evt-3", + idempotencyKey: "idem-3", + occurredAt: "2026-07-20T10:00:00.000Z", + provider: "anthropic", + }), + ] + const query = makeQuery({ groupBy: ["day", "provider"] }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(3) + }) + + it("should group by day + provider + model (3 axes)", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + occurredAt: "2026-07-19T10:00:00.000Z", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + }), + makeEvent({ + eventId: "evt-2", + idempotencyKey: "idem-2", + occurredAt: "2026-07-19T10:00:00.000Z", + provider: "anthropic", + model: "claude-opus-4-20250514", + }), + makeEvent({ + eventId: "evt-3", + idempotencyKey: "idem-3", + occurredAt: "2026-07-19T10:00:00.000Z", + provider: "openai", + model: "gpt-4o", + }), + ] + const query = makeQuery({ groupBy: ["day", "provider", "model"] }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(3) + }) + }) + + describe("query - source grouping", () => { + it("should separate events by cost source", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + usage: { costUsd: { value: 0.01, source: "provider" } }, + }), + makeEvent({ + eventId: "evt-2", + idempotencyKey: "idem-2", + usage: { costUsd: { value: 0.02, source: "estimated" } }, + }), + makeEvent({ + eventId: "evt-3", + idempotencyKey: "idem-3", + usage: { costUsd: { value: 0.03, source: "backfilled" } }, + }), + ] + const query = makeQuery({ groupBy: ["source"] }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(3) + const sources = result.buckets.map((b) => b.key.source).sort() + expect(sources).toEqual(["backfilled", "estimated", "provider"]) + }) + }) + + describe("query - inclusion semantics", () => { + it("should count unknownEventCount when inclusion is unknown", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + semantics: { + cacheReadInInput: "unknown", + cacheWriteInInput: "excluded", + reasoningInOutput: "excluded", + }, + }), + ] + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.totals.unknownEventCount).toBe(1) + }) + + it("should not count unknownEventCount when all inclusions are known", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + semantics: { + cacheReadInInput: "included", + cacheWriteInInput: "excluded", + reasoningInOutput: "excluded", + }, + }), + ] + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.totals.unknownEventCount).toBe(0) + }) + + it("should accumulate cacheReadTokens regardless of inclusion rule", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + usage: { + cacheReadTokens: { value: 200, source: "provider" }, + }, + semantics: { + cacheReadInInput: "included", + cacheWriteInInput: "excluded", + reasoningInOutput: "excluded", + }, + }), + ] + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.totals.cacheReadTokens).toBe(200) + }) + }) + + describe("query - time range filtering", () => { + it("should filter events by preset 'today'", () => { + const now = new Date() + const todayIso = now.toISOString() + const pastDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString() + + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", occurredAt: todayIso }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", occurredAt: pastDate }), + ] + const query = makeQuery({ preset: "today", groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.totals.events).toBe(1) + }) + + it("should filter events by preset '7d'", () => { + const now = new Date() + const recentIso = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString() + const oldIso = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString() + + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", occurredAt: recentIso }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", occurredAt: oldIso }), + ] + const query = makeQuery({ preset: "7d", groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.totals.events).toBe(1) + }) + + it("should include all events with preset 'all'", () => { + const now = new Date() + const oldIso = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString() + + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", occurredAt: now.toISOString() }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", occurredAt: oldIso }), + ] + const query = makeQuery({ preset: "all", groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.totals.events).toBe(2) + }) + + it("should filter events by explicit from/to", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", occurredAt: "2026-07-19T10:00:00.000Z" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", occurredAt: "2026-07-20T10:00:00.000Z" }), + makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3", occurredAt: "2026-07-21T10:00:00.000Z" }), + ] + const query = makeQuery({ + from: "2026-07-20T00:00:00.000Z", + to: "2026-07-21T00:00:00.000Z", + groupBy: [], + }) + + const result = aggregator.query(events, query) + + expect(result.totals.events).toBe(1) + }) + }) + + describe("query - coverage", () => { + it("should compute firstEventAt and lastEventAt", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", occurredAt: "2026-07-19T10:00:00.000Z" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", occurredAt: "2026-07-20T10:00:00.000Z" }), + makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3", occurredAt: "2026-07-21T10:00:00.000Z" }), + ] + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.coverage.firstEventAt).toBe("2026-07-19T10:00:00.000Z") + expect(result.coverage.lastEventAt).toBe("2026-07-21T10:00:00.000Z") + }) + + it("should count backfilled events in coverage", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", provenance: "live" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", provenance: "history-backfill" }), + makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3", provenance: "history-backfill" }), + ] + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.coverage.backfilledEventCount).toBe(2) + }) + + it("should pass recordingPaused option to coverage", () => { + const query = makeQuery({ groupBy: [] }) + const result = aggregator.query([], query, { recordingPaused: true }) + + expect(result.coverage.recordingPaused).toBe(true) + }) + }) + + describe("query - sorting", () => { + it("should sort category buckets by totalTokens descending then name ascending", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + provider: "openai", + usage: { inputTokens: { value: 1000, source: "provider" } }, + }), + makeEvent({ + eventId: "evt-2", + idempotencyKey: "idem-2", + provider: "anthropic", + usage: { inputTokens: { value: 3000, source: "provider" } }, + }), + makeEvent({ + eventId: "evt-3", + idempotencyKey: "idem-3", + provider: "google", + usage: { inputTokens: { value: 2000, source: "provider" } }, + }), + ] + const query = makeQuery({ groupBy: ["provider"] }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(3) + // totalTokens descending: anthropic(3000) > google(2000) > openai(1000) + expect(result.buckets[0].key.provider).toBe("anthropic") + expect(result.buckets[1].key.provider).toBe("google") + expect(result.buckets[2].key.provider).toBe("openai") + }) + }) + + describe("query - missing values", () => { + it("should handle events with missing usage fields", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + usage: {}, // all usage fields missing + }), + ] + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.totals.events).toBe(1) + expect(result.totals.inputTokens).toBe(0) + expect(result.totals.outputTokens).toBe(0) + expect(result.totals.costUsd).toBe(0) + }) + + it("should default missing SourcedNumber value to 0", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + usage: { + inputTokens: { value: 1000, source: "provider" }, + // outputTokens, cacheRead, cacheWrite, reasoning, total, cost all missing + }, + }), + ] + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.totals.inputTokens).toBe(1000) + expect(result.totals.outputTokens).toBe(0) + expect(result.totals.cacheReadTokens).toBe(0) + expect(result.totals.cacheWriteTokens).toBe(0) + expect(result.totals.reasoningTokens).toBe(0) + // totalTokens is recomputed as inputTokens + outputTokens (1000 + 0 = 1000), + // not read from the stored event.usage.totalTokens field. + expect(result.totals.totalTokens).toBe(1000) + // Feature 1: When costUsd is missing, the aggregator now computes + // the cost on-the-fly from the model's pricing info. The default + // test event uses provider "anthropic" + model "claude-sonnet-4-20250514" + // with 1000 input tokens. Anthropic pricing: $3/1M input tokens → + // 1000 × 3 / 1_000_000 = 0.003. + expect(result.totals.costUsd).toBeCloseTo(0.003, 5) + }) + + it("should not double-count cache/reasoning tokens in totalTokens", () => { + // Regression test: totalTokens must equal inputTokens + outputTokens only. + // Cache tokens are a subset of input; reasoning tokens are a subset of output. + // See docs/260720_22_gitignore-heatmap-fix/213200_debug-report.md + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + usage: { + inputTokens: { value: 100, source: "provider" }, + outputTokens: { value: 50, source: "provider" }, + cacheReadTokens: { value: 40, source: "provider" }, + cacheWriteTokens: { value: 10, source: "provider" }, + reasoningTokens: { value: 20, source: "provider" }, + // Deliberately set a bad stored totalTokens (old double-counted sum) + totalTokens: { value: 220, source: "provider" }, + costUsd: { value: 0.01, source: "provider" }, + }, + }), + ] + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query(events, query) + + // 100 + 50 = 150, NOT 220 (100 + 50 + 40 + 10 + 20) + expect(result.totals.totalTokens).toBe(150) + expect(result.totals.inputTokens).toBe(100) + expect(result.totals.outputTokens).toBe(50) + expect(result.totals.cacheReadTokens).toBe(40) + expect(result.totals.cacheWriteTokens).toBe(10) + expect(result.totals.reasoningTokens).toBe(20) + }) + }) + + // ── Week and Month grouping ─────────────────────────────────────────── + + describe("query - week grouping", () => { + it("should group events by ISO week bucket", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", occurredAt: "2026-07-13T10:00:00.000Z" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", occurredAt: "2026-07-15T10:00:00.000Z" }), + makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3", occurredAt: "2026-07-20T10:00:00.000Z" }), + ] + const query = makeQuery({ groupBy: ["week"] }) + + const result = aggregator.query(events, query) + + // 2026-07-13 KST = 2026-07-13 19:00 → ISO week 28 + // 2026-07-15 KST = 2026-07-15 19:00 → ISO week 29 + // 2026-07-20 KST = 2026-07-20 19:00 → ISO week 29 + expect(result.buckets.length).toBeGreaterThanOrEqual(1) + const weekKeys = result.buckets.map((b) => b.key.week) + weekKeys.forEach((key) => { + expect(key).toMatch(/^\d{4}-W\d{2}$/) + }) + }) + + it("should sort week buckets in ascending order", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", occurredAt: "2026-07-20T10:00:00.000Z" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", occurredAt: "2026-07-13T10:00:00.000Z" }), + ] + const query = makeQuery({ groupBy: ["week"] }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(2) + // week key is a string in "YYYY-Www" format, so string comparison is used + const firstWeek = result.buckets[0].key.week ?? "" + const secondWeek = result.buckets[1].key.week ?? "" + expect(firstWeek.localeCompare(secondWeek)).toBeLessThan(0) + }) + }) + + describe("query - month grouping", () => { + it("should group events by month bucket", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", occurredAt: "2026-07-19T10:00:00.000Z" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", occurredAt: "2026-07-20T10:00:00.000Z" }), + makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3", occurredAt: "2026-08-15T10:00:00.000Z" }), + ] + const query = makeQuery({ groupBy: ["month"] }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(2) + const monthKeys = result.buckets.map((b) => b.key.month).sort() + expect(monthKeys).toContain("2026-07") + expect(monthKeys).toContain("2026-08") + }) + + it("should sort month buckets in ascending order", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", occurredAt: "2026-08-15T10:00:00.000Z" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", occurredAt: "2026-07-19T10:00:00.000Z" }), + ] + const query = makeQuery({ groupBy: ["month"] }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(2) + expect(result.buckets[0].key.month).toBe("2026-07") + expect(result.buckets[1].key.month).toBe("2026-08") + }) + }) + + // ── Status grouping ──────────────────────────────────────────────────── + + describe("query - status grouping", () => { + it("should group events by status", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", status: "completed" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", status: "completed" }), + makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3", status: "failed" }), + makeEvent({ eventId: "evt-4", idempotencyKey: "idem-4", status: "cancelled" }), + ] + const query = makeQuery({ groupBy: ["status"], includeCancelled: true }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(3) + const statuses = result.buckets.map((b) => b.key.status).sort() + expect(statuses).toEqual(["cancelled", "completed", "failed"]) + }) + + it("should exclude cancelled from status grouping when includeCancelled is false", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", status: "completed" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", status: "cancelled" }), + ] + const query = makeQuery({ groupBy: ["status"], includeCancelled: false }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(1) + expect(result.buckets[0].key.status).toBe("completed") + }) + }) + + // ── Inclusion semantics edge cases ───────────────────────────────────── + + describe("query - inclusion semantics edge cases", () => { + it("should accumulate cacheWriteTokens regardless of cacheWriteInInput value", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + usage: { + cacheWriteTokens: { value: 500, source: "provider" }, + }, + semantics: { + cacheReadInInput: "excluded", + cacheWriteInInput: "included", + reasoningInOutput: "excluded", + }, + }), + ] + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.totals.cacheWriteTokens).toBe(500) + }) + + it("should accumulate reasoningTokens regardless of reasoningInOutput value", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + usage: { + reasoningTokens: { value: 800, source: "provider" }, + }, + semantics: { + cacheReadInInput: "excluded", + cacheWriteInInput: "excluded", + reasoningInOutput: "included", + }, + }), + ] + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.totals.reasoningTokens).toBe(800) + }) + + it("should count unknownEventCount when cacheWriteInInput is unknown", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + semantics: { + cacheReadInInput: "excluded", + cacheWriteInInput: "unknown", + reasoningInOutput: "excluded", + }, + }), + ] + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.totals.unknownEventCount).toBe(1) + }) + + it("should count unknownEventCount when reasoningInOutput is unknown", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + semantics: { + cacheReadInInput: "excluded", + cacheWriteInInput: "excluded", + reasoningInOutput: "unknown", + }, + }), + ] + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.totals.unknownEventCount).toBe(1) + }) + + it("should count unknownEventCount once even when multiple inclusions are unknown", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + semantics: { + cacheReadInInput: "unknown", + cacheWriteInInput: "unknown", + reasoningInOutput: "unknown", + }, + }), + ] + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query(events, query) + + // Even with multiple unknowns in one event, only increments by 1 + expect(result.totals.unknownEventCount).toBe(1) + }) + }) + + // ── Source grouping edge cases ───────────────────────────────────────── + + describe("query - source grouping edge cases", () => { + it("should group by 'unknown' source when event has no costUsd or token sources", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + usage: {}, // all usage fields missing + }), + ] + const query = makeQuery({ groupBy: ["source"] }) + + const result = aggregator.query(events, query) + + expect(result.buckets).toHaveLength(1) + expect(result.buckets[0].key.source).toBe("unknown") + }) + + it("should create separate buckets for different token sources within one event", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + usage: { + inputTokens: { value: 1000, source: "provider" }, + outputTokens: { value: 500, source: "estimated" }, + costUsd: { value: 0.01, source: "backfilled" }, + }, + }), + ] + const query = makeQuery({ groupBy: ["source"] }) + + const result = aggregator.query(events, query) + + // 3 different sources → 3 buckets + expect(result.buckets).toHaveLength(3) + const sources = result.buckets.map((b) => b.key.source).sort() + expect(sources).toEqual(["backfilled", "estimated", "provider"]) + }) + }) + + // ── Multi-axis sorting ───────────────────────────────────────────────── + + describe("query - multi-axis sorting", () => { + it("should sort by time axis when time axis is present in multi-axis grouping", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + occurredAt: "2026-07-20T10:00:00.000Z", + provider: "anthropic", + }), + makeEvent({ + eventId: "evt-2", + idempotencyKey: "idem-2", + occurredAt: "2026-07-19T10:00:00.000Z", + provider: "openai", + }), + makeEvent({ + eventId: "evt-3", + idempotencyKey: "idem-3", + occurredAt: "2026-07-19T10:00:00.000Z", + provider: "anthropic", + }), + ] + const query = makeQuery({ groupBy: ["day", "provider"] }) + + const result = aggregator.query(events, query) + + // Sort ascending by time axis + expect(result.buckets.length).toBeGreaterThanOrEqual(2) + for (let i = 1; i < result.buckets.length; i++) { + const prev = result.buckets[i - 1].key.day ?? "" + const curr = result.buckets[i].key.day ?? "" + expect(prev.localeCompare(curr)).toBeLessThanOrEqual(0) + } + }) + + it("should sort category buckets by name ascending when totalTokens are equal", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + provider: "zeta", + usage: { inputTokens: { value: 1000, source: "provider" } }, + }), + makeEvent({ + eventId: "evt-2", + idempotencyKey: "idem-2", + provider: "alpha", + usage: { inputTokens: { value: 1000, source: "provider" } }, + }), + ] + const query = makeQuery({ groupBy: ["provider"] }) + + const result = aggregator.query(events, query) + + // Same totalTokens → name ascending + expect(result.buckets[0].key.provider).toBe("alpha") + expect(result.buckets[1].key.provider).toBe("zeta") + }) + }) + + // ── Coverage edge cases ──────────────────────────────────────────────── + + describe("query - coverage edge cases", () => { + it("should return undefined firstEventAt and lastEventAt for empty visible events", () => { + const query = makeQuery({ groupBy: [] }) + const result = aggregator.query([], query) + + expect(result.coverage.firstEventAt).toBeUndefined() + expect(result.coverage.lastEventAt).toBeUndefined() + }) + + it("should compute firstEventAt and lastEventAt from visible (non-cancelled) events only", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + occurredAt: "2026-07-19T10:00:00.000Z", + status: "cancelled", + }), + makeEvent({ + eventId: "evt-2", + idempotencyKey: "idem-2", + occurredAt: "2026-07-20T10:00:00.000Z", + status: "completed", + }), + makeEvent({ + eventId: "evt-3", + idempotencyKey: "idem-3", + occurredAt: "2026-07-21T10:00:00.000Z", + status: "completed", + }), + ] + const query = makeQuery({ groupBy: [], includeCancelled: false }) + + const result = aggregator.query(events, query) + + // Cancelled events are excluded from coverage + expect(result.coverage.firstEventAt).toBe("2026-07-20T10:00:00.000Z") + expect(result.coverage.lastEventAt).toBe("2026-07-21T10:00:00.000Z") + }) + + it("should count only visible backfilled events in coverage", () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + provenance: "history-backfill", + status: "completed", + }), + makeEvent({ + eventId: "evt-2", + idempotencyKey: "idem-2", + provenance: "history-backfill", + status: "cancelled", + }), + makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3", provenance: "live", status: "completed" }), + ] + const query = makeQuery({ groupBy: [], includeCancelled: false }) + + const result = aggregator.query(events, query) + + // Cancelled backfill events are excluded from visible, so only 1 is counted + expect(result.coverage.backfilledEventCount).toBe(1) + }) + }) + + // ── Empty groupBy ────────────────────────────────────────────────────── + + describe("query - empty groupBy", () => { + it("should return a single empty-key bucket when groupBy is empty", () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2" }), + ] + const query = makeQuery({ groupBy: [] }) + + const result = aggregator.query(events, query) + + // Empty groupBy → single bucket with empty key + expect(result.buckets).toHaveLength(1) + expect(Object.keys(result.buckets[0].key)).toHaveLength(0) + expect(result.buckets[0].events).toBe(2) + }) + }) + + // ── Preset 30d filtering ──────────────────────────────────────────────── + + describe("query - preset 30d filtering", () => { + it("should filter events by preset '30d'", () => { + const now = new Date() + const recentIso = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString() + const oldIso = new Date(now.getTime() - 100 * 24 * 60 * 60 * 1000).toISOString() + + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", occurredAt: recentIso }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", occurredAt: oldIso }), + ] + const query = makeQuery({ preset: "30d", groupBy: [] }) + + const result = aggregator.query(events, query) + + expect(result.totals.events).toBe(1) + }) + }) + + // ── Snapshot structure ───────────────────────────────────────────────── + + describe("query - snapshot structure", () => { + it("should return snapshot with query, generatedAt, buckets, totals, and coverage", () => { + const query = makeQuery({ groupBy: [] }) + const result = aggregator.query([], query) + + expect(result.query).toEqual(query) + expect(result.generatedAt).toBeTruthy() + expect(Array.isArray(result.buckets)).toBe(true) + expect(result.totals).toBeDefined() + expect(result.coverage).toBeDefined() + }) + + it("should return generatedAt as a valid ISO date string", () => { + const query = makeQuery({ groupBy: [] }) + const result = aggregator.query([], query) + + const parsed = new Date(result.generatedAt) + expect(parsed.getTime()).not.toBeNaN() + }) + }) +}) diff --git a/src/services/stats/__tests__/UsageEventStore.spec.ts b/src/services/stats/__tests__/UsageEventStore.spec.ts new file mode 100644 index 0000000000..a9a0379388 --- /dev/null +++ b/src/services/stats/__tests__/UsageEventStore.spec.ts @@ -0,0 +1,379 @@ +import * as path from "path" +import * as fs from "fs/promises" +import * as os from "os" + +import { describe, it, expect, beforeEach, afterEach } from "vitest" + +import type { UsageEventV1 } from "@roo-code/types" + +import { UsageEventStore, StatsStoreError } from "../UsageEventStore" + +// ── Test Helpers ──────────────────────────────────────────────────────────── + +/** + * Creates a temporary directory for testing. + * Does not touch the actual global storage. + */ +async function createTempDir(): Promise { + const prefix = path.join(os.tmpdir(), "usage-stats-test-") + return fs.mkdtemp(prefix) +} + +/** + * Creates a UsageEventV1 event for testing. + */ +function makeEvent(overrides: Partial = {}): UsageEventV1 { + return { + schemaVersion: 1, + eventId: `evt-${Math.random().toString(36).slice(2)}`, + idempotencyKey: `idem-${Math.random().toString(36).slice(2)}`, + occurredAt: new Date().toISOString(), + timezoneOffsetMinutes: 540, // KST UTC+9 + status: "completed", + attempt: 1, + taskId: "task-001", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + mode: "code", + usage: { + inputTokens: { value: 1000, source: "provider" }, + outputTokens: { value: 500, source: "provider" }, + costUsd: { value: 0.01, source: "provider" }, + }, + semantics: { + cacheReadInInput: "excluded", + cacheWriteInInput: "excluded", + reasoningInOutput: "excluded", + }, + provenance: "live", + ...overrides, + } +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("UsageEventStore", () => { + let tempDir: string + let store: UsageEventStore + + beforeEach(async () => { + tempDir = await createTempDir() + store = new UsageEventStore(tempDir) + await store.initialize() + }) + + afterEach(async () => { + // Clean up temp directory (test isolation) + try { + await fs.rm(tempDir, { recursive: true, force: true }) + } catch { + // ignore cleanup errors + } + }) + + describe("initialize", () => { + it("should create stats directory structure", async () => { + const statsDir = store._getStatsDir() + const dirExists = await fs.access(statsDir).then(() => true).catch(() => false) + expect(dirExists).toBe(true) + + const quarantineDir = path.join(statsDir, "quarantine") + const quarantineExists = await fs.access(quarantineDir).then(() => true).catch(() => false) + expect(quarantineExists).toBe(true) + }) + + it("should create manifest.json on first init", async () => { + const manifestPath = path.join(store._getStatsDir(), "manifest.json") + const content = await fs.readFile(manifestPath, "utf-8") + const manifest = JSON.parse(content) + expect(manifest.manifestVersion).toBe(1) + expect(manifest.generation).toBe(1) + expect(manifest.currentSegment).toBe(1) + }) + + it("should be idempotent (multiple initialize calls)", async () => { + await store.initialize() + await store.initialize() + // should not throw + }) + }) + + describe("append", () => { + it("should append a valid event", async () => { + const event = makeEvent() + const result = await store.append(event) + expect(result).toBe(true) + + const events = await store.readAll() + expect(events).toHaveLength(1) + expect(events[0].eventId).toBe(event.eventId) + }) + + it("should deduplicate by idempotencyKey", async () => { + const event = makeEvent() + const result1 = await store.append(event) + const result2 = await store.append(event) + + expect(result1).toBe(true) + expect(result2).toBe(false) + + const events = await store.readAll() + expect(events).toHaveLength(1) + }) + + it("should append multiple different events", async () => { + const event1 = makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1" }) + const event2 = makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2" }) + const event3 = makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3" }) + + await store.append(event1) + await store.append(event2) + await store.append(event3) + + const events = await store.readAll() + expect(events).toHaveLength(3) + }) + + it("should persist events to NDJSON file", async () => { + const event = makeEvent() + await store.append(event) + + const segmentPath = path.join(store._getStatsDir(), "events-000001.ndjson") + const content = await fs.readFile(segmentPath, "utf-8") + const lines = content.trim().split("\n") + expect(lines).toHaveLength(1) + + const parsed = JSON.parse(lines[0]) + expect(parsed.eventId).toBe(event.eventId) + }) + + it("should persist optional endpoint field when provided", async () => { + const event = makeEvent({ endpoint: "kimi.ai" }) + await store.append(event) + + const events = await store.readAll() + expect(events).toHaveLength(1) + expect(events[0].endpoint).toBe("kimi.ai") + }) + + it("should not require endpoint field (backward compatible)", async () => { + const event = makeEvent() + await store.append(event) + + const events = await store.readAll() + expect(events).toHaveLength(1) + expect(events[0].endpoint).toBeUndefined() + }) + + it("should serialize concurrent appends via promise queue", async () => { + const events = Array.from({ length: 10 }, (_, i) => + makeEvent({ eventId: `evt-${i}`, idempotencyKey: `idem-${i}` }), + ) + + const results = await Promise.all(events.map((e) => store.append(e))) + expect(results.every((r) => r === true)).toBe(true) + + const stored = await store.readAll() + expect(stored).toHaveLength(10) + }) + + it("should invalidate cache when a segment rotation happens during append", async () => { + // Force the current segment to be just under the rotation threshold + // by writing a large payload to the segment file directly. + const segmentPath = path.join(store._getStatsDir(), "events-000001.ndjson") + const paddingSize = 5 * 1024 * 1024 // 5 MiB + const padding = "{" + "a".repeat(paddingSize) + "}\n" + await fs.writeFile(segmentPath, padding, "utf-8") + + // Prime the cache so the next readAll would return the cached snapshot. + await store.readAll() + + // Append a valid event. The segment is already at the rotation threshold, + // so appendInternal will rotate to segment 2. The cache must be + // invalidated because the cached snapshot no longer reflects the new + // segment layout. + const event = makeEvent({ eventId: "evt-rot", idempotencyKey: "idem-rot" }) + const result = await store.append(event) + expect(result).toBe(true) + + // The next readAll should rescan from disk and include the appended event. + const stored = await store.readAll() + expect(stored).toHaveLength(1) + expect(stored[0].eventId).toBe("evt-rot") + }) + }) + + describe("readAll", () => { + it("should return empty array when no events", async () => { + const events = await store.readAll() + expect(events).toHaveLength(0) + }) + + it("should read all events in order", async () => { + const event1 = makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", occurredAt: "2026-07-19T10:00:00.000Z" }) + const event2 = makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", occurredAt: "2026-07-19T11:00:00.000Z" }) + + await store.append(event1) + await store.append(event2) + + const events = await store.readAll() + expect(events).toHaveLength(2) + expect(events[0].eventId).toBe("evt-1") + expect(events[1].eventId).toBe("evt-2") + }) + + it("should skip corrupt lines and continue reading", async () => { + const event = makeEvent() + await store.append(event) + + // Manually add a corrupt line + const segmentPath = path.join(store._getStatsDir(), "events-000001.ndjson") + await fs.appendFile(segmentPath, "{invalid json line\n") + + const events = await store.readAll() + expect(events).toHaveLength(1) // corrupt line is skipped + }) + + it("should ignore truncated last line (crash tail)", async () => { + const event = makeEvent() + await store.append(event) + + // Manually add a truncated line (last line) + const segmentPath = path.join(store._getStatsDir(), "events-000001.ndjson") + await fs.appendFile(segmentPath, '{"partial": tru') // Truncated JSON + + const events = await store.readAll() + expect(events).toHaveLength(1) // crash tail is ignored + }) + + it("should write quarantine report for corrupt lines", async () => { + const event = makeEvent() + await store.append(event) + + // Add a corrupt line in the middle (not the last position) + const segmentPath = path.join(store._getStatsDir(), "events-000001.ndjson") + const validLine = JSON.stringify(makeEvent({ eventId: "evt-valid", idempotencyKey: "idem-valid" })) + "\n" + await fs.appendFile(segmentPath, "{corrupt\n") + await fs.appendFile(segmentPath, validLine) + + await store.readAll() + + const quarantinePath = path.join(store._getStatsDir(), "quarantine", "corrupt-lines.jsonl") + const quarantineExists = await fs.access(quarantinePath).then(() => true).catch(() => false) + expect(quarantineExists).toBe(true) + }) + + it("should not cache corrupt lines from a crash tail on first readAll", async () => { + const event = makeEvent({ eventId: "evt-clean", idempotencyKey: "idem-clean" }) + await store.append(event) + + const segmentPath = path.join(store._getStatsDir(), "events-000001.ndjson") + // Append a corrupt middle line followed by a valid line, then a truncated + // crash tail as the very last line. The crash tail should be ignored and + // must not appear in the cached snapshot on subsequent reads. + const validLine = JSON.stringify(makeEvent({ eventId: "evt-valid", idempotencyKey: "idem-valid" })) + "\n" + await fs.appendFile(segmentPath, "{corrupt middle\n") + await fs.appendFile(segmentPath, validLine) + await fs.appendFile(segmentPath, '{"partial": tru') + + const firstRead = await store.readAll() + expect(firstRead).toHaveLength(2) + expect(firstRead.map((e) => e.eventId)).toContain("evt-clean") + expect(firstRead.map((e) => e.eventId)).toContain("evt-valid") + + // A second readAll should return the exact same cached snapshot without + // reintroducing the crash tail or corrupt middle line. + const secondRead = await store.readAll() + expect(secondRead).toHaveLength(2) + expect(secondRead.map((e) => e.eventId)).toEqual(firstRead.map((e) => e.eventId)) + }) + + it("should rescan when more segments exist on disk than cached segment count", async () => { + const event = makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1" }) + await store.append(event) + + // Prime the cache. + await store.readAll() + + // Simulate an external writer (or another process) creating segment 2 + // directly with a valid event. + const segment2Path = path.join(store._getStatsDir(), "events-000002.ndjson") + const externalEvent = makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2" }) + await fs.writeFile(segment2Path, JSON.stringify(externalEvent) + "\n", "utf-8") + + // The cached snapshot only knew about segment 1. readAll must detect the + // extra segment and invalidate the cache so the external event is included. + const events = await store.readAll() + expect(events).toHaveLength(2) + expect(events.map((e) => e.eventId)).toContain("evt-1") + expect(events.map((e) => e.eventId)).toContain("evt-2") + }) + }) + + describe("clear", () => { + it("should clear all events and increment generation", async () => { + await store.append(makeEvent({ idempotencyKey: "idem-1" })) + await store.append(makeEvent({ idempotencyKey: "idem-2" })) + + await store.clear() + + const events = await store.readAll() + expect(events).toHaveLength(0) + + const manifest = await store.getManifest() + expect(manifest.generation).toBe(2) + expect(manifest.currentSegment).toBe(1) + }) + + it("should reset idempotency set after clear", async () => { + const event = makeEvent({ idempotencyKey: "idem-same" }) + await store.append(event) + + await store.clear() + + // After clear, the same idempotencyKey can be appended again + const result = await store.append(event) + expect(result).toBe(true) + }) + + it("should move old segments to old-generation directory", async () => { + await store.append(makeEvent()) + + await store.clear() + + const oldGenDir = path.join(store._getStatsDir(), "old-generation-1") + const oldGenExists = await fs.access(oldGenDir).then(() => true).catch(() => false) + expect(oldGenExists).toBe(true) + }) + }) + + describe("idempotency recovery on restart", () => { + it("should rebuild idempotency set from segment scan on re-init", async () => { + const event = makeEvent({ idempotencyKey: "idem-persist" }) + await store.append(event) + + // Create a new store instance (simulate restart) + const newStore = new UsageEventStore(tempDir) + await newStore.initialize() + + // Attempt to append with the same idempotencyKey → should be deduped + const result = await newStore.append(event) + expect(result).toBe(false) + }) + }) + + describe("error handling", () => { + it("should throw StatsStoreError with correct code on cap reached", async () => { + // This test is difficult to force the cap, so only verify isCapped() method behavior + expect(store.isCapped()).toBe(false) + }) + + it("should not throw on duplicate append (idempotent)", async () => { + const event = makeEvent() + await store.append(event) + + // Re-appending the same event is not an error + await expect(store.append(event)).resolves.toBe(false) + }) + }) +}) diff --git a/src/services/stats/__tests__/UsageStatsService.spec.ts b/src/services/stats/__tests__/UsageStatsService.spec.ts new file mode 100644 index 0000000000..5d2af85b80 --- /dev/null +++ b/src/services/stats/__tests__/UsageStatsService.spec.ts @@ -0,0 +1,789 @@ +import * as path from "path" +import * as fs from "fs/promises" +import * as os from "os" + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" + +import type { UsageEventV1, StatsQuery } from "@roo-code/types" + +import { UsageStatsService, StatsServiceError } from "../UsageStatsService" +import { StatsStoreError } from "../UsageEventStore" + +// ── Test Helpers ──────────────────────────────────────────────────────────── + +/** + * Creates a temporary directory for testing. + * Does not touch the actual global storage. + */ +async function createTempDir(): Promise { + const prefix = path.join(os.tmpdir(), "usage-stats-svc-test-") + return fs.mkdtemp(prefix) +} + +/** + * Creates a UsageEventV1 event for testing. + */ +function makeEvent(overrides: Partial = {}): UsageEventV1 { + return { + schemaVersion: 1, + eventId: `evt-${Math.random().toString(36).slice(2)}`, + idempotencyKey: `idem-${Math.random().toString(36).slice(2)}`, + occurredAt: "2026-07-19T10:00:00.000Z", + timezoneOffsetMinutes: 540, // KST UTC+9 + status: "completed", + attempt: 1, + taskId: "task-001", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + mode: "code", + usage: { + inputTokens: { value: 1000, source: "provider" }, + outputTokens: { value: 500, source: "provider" }, + costUsd: { value: 0.01, source: "provider" }, + }, + semantics: { + cacheReadInInput: "excluded", + cacheWriteInInput: "excluded", + reasoningInOutput: "excluded", + }, + provenance: "live", + ...overrides, + } +} + +/** + * Creates a default StatsQuery. + */ +function makeQuery(overrides: Partial = {}): StatsQuery { + return { + timezone: "Asia/Seoul", + groupBy: ["day"], + includeCancelled: false, + ...overrides, + } +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("UsageStatsService", () => { + let tempDir: string + let service: UsageStatsService + + beforeEach(async () => { + tempDir = await createTempDir() + service = new UsageStatsService(tempDir) + await service.initialize() + }) + + afterEach(async () => { + // Clean up temp directory (test isolation) + try { + await fs.rm(tempDir, { recursive: true, force: true }) + } catch { + // ignore cleanup errors + } + }) + + // ── initialize ────────────────────────────────────────────────────────── + + describe("initialize", () => { + it("should create the stats directory structure on initialize", async () => { + const statsDir = path.join(tempDir, "usage-stats") + const dirExists = await fs.access(statsDir).then(() => true).catch(() => false) + expect(dirExists).toBe(true) + }) + + it("should be idempotent (calling initialize twice does not throw)", async () => { + // Second call is a no-op + await expect(service.initialize()).resolves.toBeUndefined() + }) + }) + + // ── queryStats ────────────────────────────────────────────────────────── + + describe("queryStats", () => { + it("should return empty snapshot when no events exist", async () => { + const query = makeQuery() + const result = await service.queryStats(query) + + expect(result.buckets).toHaveLength(0) + expect(result.totals.events).toBe(0) + expect(result.coverage.firstEventAt).toBeUndefined() + expect(result.coverage.lastEventAt).toBeUndefined() + }) + + it("should aggregate events stored via the underlying store", async () => { + // Cannot directly access the internal store of the service, so inject events via backfill. + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + occurredAt: "2026-07-19T10:00:00.000Z", + usage: { + inputTokens: { value: 1000, source: "provider" }, + outputTokens: { value: 500, source: "provider" }, + costUsd: { value: 0.01, source: "provider" }, + }, + }), + makeEvent({ + eventId: "evt-2", + idempotencyKey: "idem-2", + occurredAt: "2026-07-19T15:00:00.000Z", + usage: { + inputTokens: { value: 2000, source: "provider" }, + outputTokens: { value: 1000, source: "provider" }, + costUsd: { value: 0.02, source: "provider" }, + }, + }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ groupBy: ["day"] }) + const result = await service.queryStats(query) + + expect(result.totals.events).toBe(2) + expect(result.totals.inputTokens).toBe(3000) + expect(result.totals.outputTokens).toBe(1500) + expect(result.totals.costUsd).toBeCloseTo(0.03, 5) + }) + + it("should pass recordingPaused option through to the snapshot coverage", async () => { + const query = makeQuery() + const result = await service.queryStats(query, { recordingPaused: true }) + + expect(result.coverage.recordingPaused).toBe(true) + }) + + it("should default recordingPaused to false when not provided", async () => { + const query = makeQuery() + const result = await service.queryStats(query) + + expect(result.coverage.recordingPaused).toBe(false) + }) + }) + + // ── exportStats ───────────────────────────────────────────────────────── + + describe("exportStats - JSON", () => { + it("should export events as JSON with correct schema", async () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2" }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all" }) + const result = await service.exportStats(query, "json") + + expect(result).not.toBe("string") + const jsonExport = result as { + exportSchemaVersion: number + exportedAt: string + query: StatsQuery + events: UsageEventV1[] + } + + expect(jsonExport.exportSchemaVersion).toBe(1) + expect(jsonExport.exportedAt).toBeTruthy() + expect(jsonExport.query).toEqual(query) + expect(jsonExport.events).toHaveLength(2) + }) + + it("should filter events by preset in JSON export", async () => { + const now = new Date() + const recentIso = now.toISOString() + const oldIso = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString() + + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", occurredAt: recentIso }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", occurredAt: oldIso }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "today" }) + const result = await service.exportStats(query, "json") + const jsonExport = result as { events: UsageEventV1[] } + + // oldIso is outside the today range, so only 1 remains + expect(jsonExport.events).toHaveLength(1) + expect(jsonExport.events[0].eventId).toBe("evt-1") + }) + + it("should exclude cancelled events by default in JSON export", async () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", status: "completed" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", status: "cancelled" }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all", includeCancelled: false }) + const result = await service.exportStats(query, "json") + const jsonExport = result as { events: UsageEventV1[] } + + expect(jsonExport.events).toHaveLength(1) + expect(jsonExport.events[0].status).toBe("completed") + }) + + it("should include cancelled events when includeCancelled is true", async () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", status: "completed" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", status: "cancelled" }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all", includeCancelled: true }) + const result = await service.exportStats(query, "json") + const jsonExport = result as { events: UsageEventV1[] } + + expect(jsonExport.events).toHaveLength(2) + }) + + it("should export empty events array when no data exists", async () => { + const query = makeQuery({ preset: "all" }) + const result = await service.exportStats(query, "json") + const jsonExport = result as { events: UsageEventV1[] } + + expect(jsonExport.events).toHaveLength(0) + }) + }) + + describe("exportStats - CSV", () => { + it("should export events as CSV with header row", async () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1" }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all" }) + const result = await service.exportStats(query, "csv") + + expect(typeof result).toBe("string") + const lines = (result as string).split("\n") + // header + 1 data row + expect(lines).toHaveLength(2) + expect(lines[0]).toContain("eventId") + expect(lines[0]).toContain("idempotencyKey") + expect(lines[0]).toContain("occurredAt") + expect(lines[0]).toContain("provider") + expect(lines[0]).toContain("model") + expect(lines[0]).toContain("inputTokens") + expect(lines[0]).toContain("costUsd") + expect(lines[0]).toContain("provenance") + }) + + it("should include data values in CSV rows", async () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + usage: { + inputTokens: { value: 1500, source: "provider" }, + outputTokens: { value: 750, source: "provider" }, + costUsd: { value: 0.03, source: "provider" }, + }, + }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all" }) + const result = await service.exportStats(query, "csv") + const lines = (result as string).split("\n") + const dataRow = lines[1] + + expect(dataRow).toContain("evt-1") + expect(dataRow).toContain("idem-1") + expect(dataRow).toContain("anthropic") + expect(dataRow).toContain("claude-sonnet-4-20250514") + expect(dataRow).toContain("1500") + expect(dataRow).toContain("750") + expect(dataRow).toContain("0.03") + }) + + it("should output only header when no events exist", async () => { + const query = makeQuery({ preset: "all" }) + const result = await service.exportStats(query, "csv") + + expect(typeof result).toBe("string") + const lines = (result as string).split("\n") + expect(lines).toHaveLength(1) + expect(lines[0]).toContain("eventId") + }) + + it("should escape formula injection in CSV cells (=, +, -, @ prefixes)", async () => { + const events = [ + makeEvent({ + eventId: "=evt-injection", + idempotencyKey: "idem-1", + provider: "+provider", + model: "@model", + }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all" }) + const result = await service.exportStats(query, "csv") + const lines = (result as string).split("\n") + const dataRow = lines[1] + + // Prevent formula injection: ' prefix + expect(dataRow).toContain("'=evt-injection") + expect(dataRow).toContain("'+provider") + expect(dataRow).toContain("'@model") + }) + + it("should quote cells containing commas", async () => { + const events = [ + makeEvent({ + eventId: "evt,with,commas", + idempotencyKey: "idem-1", + }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all" }) + const result = await service.exportStats(query, "csv") + const lines = (result as string).split("\n") + const dataRow = lines[1] + + // Quoting when comma is included + expect(dataRow).toContain('"evt,with,commas"') + }) + + it("should quote cells containing double quotes and escape them", async () => { + const events = [ + makeEvent({ + eventId: 'evt"with"quotes', + idempotencyKey: "idem-1", + }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all" }) + const result = await service.exportStats(query, "csv") + const lines = (result as string).split("\n") + const dataRow = lines[1] + + // Quoting + "" escape when " is included + expect(dataRow).toContain('"evt""with""quotes"') + }) + + it("should output empty cell for missing optional usage fields", async () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + usage: {}, // all usage fields missing + }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all" }) + const result = await service.exportStats(query, "csv") + const lines = (result as string).split("\n") + const headerCols = lines[0].split(",") + const dataCols = lines[1].split(",") + + // inputTokens column index + const inputTokensIdx = headerCols.indexOf("inputTokens") + expect(inputTokensIdx).toBeGreaterThanOrEqual(0) + expect(dataCols[inputTokensIdx]).toBe("") + + // costUsd column index + const costUsdIdx = headerCols.indexOf("costUsd") + expect(costUsdIdx).toBeGreaterThanOrEqual(0) + expect(dataCols[costUsdIdx]).toBe("") + }) + + it("should output empty cell for missing parentTaskId", async () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + parentTaskId: undefined, + }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all" }) + const result = await service.exportStats(query, "csv") + const lines = (result as string).split("\n") + const headerCols = lines[0].split(",") + const dataCols = lines[1].split(",") + + const parentTaskIdIdx = headerCols.indexOf("parentTaskId") + expect(parentTaskIdIdx).toBeGreaterThanOrEqual(0) + expect(dataCols[parentTaskIdIdx]).toBe("") + }) + + it("should output parentTaskId value when present", async () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + parentTaskId: "parent-001", + }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all" }) + const result = await service.exportStats(query, "csv") + const lines = (result as string).split("\n") + const headerCols = lines[0].split(",") + const dataCols = lines[1].split(",") + + const parentTaskIdIdx = headerCols.indexOf("parentTaskId") + expect(dataCols[parentTaskIdIdx]).toBe("parent-001") + }) + + it("should output source columns alongside value columns", async () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + usage: { + inputTokens: { value: 1000, source: "provider" }, + outputTokens: { value: 500, source: "estimated" }, + costUsd: { value: 0.01, source: "backfilled" }, + }, + }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all" }) + const result = await service.exportStats(query, "csv") + const lines = (result as string).split("\n") + const headerCols = lines[0].split(",") + const dataCols = lines[1].split(",") + + const inputTokensSourceIdx = headerCols.indexOf("inputTokensSource") + expect(dataCols[inputTokensSourceIdx]).toBe("provider") + + const outputTokensSourceIdx = headerCols.indexOf("outputTokensSource") + expect(dataCols[outputTokensSourceIdx]).toBe("estimated") + + const costUsdSourceIdx = headerCols.indexOf("costUsdSource") + expect(dataCols[costUsdSourceIdx]).toBe("backfilled") + }) + + it("should output semantics inclusion columns", async () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + semantics: { + cacheReadInInput: "included", + cacheWriteInInput: "excluded", + reasoningInOutput: "unknown", + }, + }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all" }) + const result = await service.exportStats(query, "csv") + const lines = (result as string).split("\n") + const headerCols = lines[0].split(",") + const dataCols = lines[1].split(",") + + const cacheReadInInputIdx = headerCols.indexOf("cacheReadInInput") + expect(dataCols[cacheReadInInputIdx]).toBe("included") + + const cacheWriteInInputIdx = headerCols.indexOf("cacheWriteInInput") + expect(dataCols[cacheWriteInInputIdx]).toBe("excluded") + + const reasoningInOutputIdx = headerCols.indexOf("reasoningInOutput") + expect(dataCols[reasoningInOutputIdx]).toBe("unknown") + }) + + it("should output provenance column", async () => { + const events = [ + makeEvent({ + eventId: "evt-1", + idempotencyKey: "idem-1", + provenance: "live", + }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all" }) + const result = await service.exportStats(query, "csv") + const lines = (result as string).split("\n") + const headerCols = lines[0].split(",") + const dataCols = lines[1].split(",") + + const provenanceIdx = headerCols.indexOf("provenance") + expect(dataCols[provenanceIdx]).toBe("history-backfill") + }) + }) + + describe("getFilteredEvents", () => { + it("should return filtered events without JSON round-trip", async () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", status: "completed" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", status: "cancelled" }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all", includeCancelled: false }) + const filtered = await service.getFilteredEvents(query) + + expect(filtered).toHaveLength(1) + expect(filtered[0].eventId).toBe("evt-1") + // Returned objects should be the same UsageEventV1 instances, not JSON + // stringified and parsed copies. + expect(filtered[0]).toBeInstanceOf(Object) + }) + }) + + describe("exportStats - invalid format", () => { + it("should throw StatsServiceError for unsupported format", async () => { + const query = makeQuery({ preset: "all" }) + + await expect( + service.exportStats(query, "xml" as "json" | "csv"), + ).rejects.toThrow(StatsServiceError) + }) + + it("should include error code STATS_SERVICE/export/001 for unsupported format", async () => { + const query = makeQuery({ preset: "all" }) + + try { + await service.exportStats(query, "xml" as "json" | "csv") + expect.fail("should have thrown") + } catch (err) { + expect(err).toBeInstanceOf(StatsServiceError) + expect((err as StatsServiceError).code).toBe("STATS_SERVICE/export/001") + } + }) + }) + + describe("exportStats - time range filtering with explicit from/to", () => { + it("should filter events by explicit from/to in export", async () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", occurredAt: "2026-07-19T10:00:00.000Z" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2", occurredAt: "2026-07-20T10:00:00.000Z" }), + makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3", occurredAt: "2026-07-21T10:00:00.000Z" }), + ] + await service.backfillFromHistory(events) + + const query = makeQuery({ + from: "2026-07-20T00:00:00.000Z", + to: "2026-07-21T00:00:00.000Z", + }) + const result = await service.exportStats(query, "json") + const jsonExport = result as { events: UsageEventV1[] } + + expect(jsonExport.events).toHaveLength(1) + expect(jsonExport.events[0].eventId).toBe("evt-2") + }) + }) + + // ── issueClearNonce ───────────────────────────────────────────────────── + + describe("issueClearNonce", () => { + it("should return a non-empty nonce string", () => { + const nonce = service.issueClearNonce() + + expect(typeof nonce).toBe("string") + expect(nonce.length).toBeGreaterThan(0) + }) + + it("should return different nonces on subsequent calls", () => { + const nonce1 = service.issueClearNonce() + const nonce2 = service.issueClearNonce() + + expect(nonce1).not.toBe(nonce2) + }) + }) + + // ── clearStats ────────────────────────────────────────────────────────── + + describe("clearStats", () => { + it("should clear stats when valid nonce is provided", async () => { + // Inject data + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2" }), + ] + await service.backfillFromHistory(events) + + // Verify before deletion + const before = await service.queryStats(makeQuery({ preset: "all" })) + expect(before.totals.events).toBe(2) + + // Issue nonce then clear + const nonce = service.issueClearNonce() + await service.clearStats(nonce) + + // Verify after deletion + const after = await service.queryStats(makeQuery({ preset: "all" })) + expect(after.totals.events).toBe(0) + }) + + it("should throw StatsServiceError when nonce is mismatched", async () => { + service.issueClearNonce() + + await expect(service.clearStats("wrong-nonce")).rejects.toThrow(StatsServiceError) + }) + + it("should include error code STATS_SERVICE/clear/001 for nonce mismatch", async () => { + service.issueClearNonce() + + try { + await service.clearStats("wrong-nonce") + expect.fail("should have thrown") + } catch (err) { + expect(err).toBeInstanceOf(StatsServiceError) + expect((err as StatsServiceError).code).toBe("STATS_SERVICE/clear/001") + } + }) + + it("should throw StatsServiceError when no nonce was issued", async () => { + await expect(service.clearStats("any-nonce")).rejects.toThrow(StatsServiceError) + }) + + it("should throw StatsServiceError when nonce has expired", async () => { + vi.useFakeTimers() + + const nonce = service.issueClearNonce() + + // After 6 minutes (nonce is valid for 5 minutes) + vi.advanceTimersByTime(6 * 60 * 1000) + + await expect(service.clearStats(nonce)).rejects.toThrow(StatsServiceError) + + vi.useRealTimers() + }) + + it("should include error code STATS_SERVICE/clear/001 for expired nonce", async () => { + vi.useFakeTimers() + + const nonce = service.issueClearNonce() + vi.advanceTimersByTime(6 * 60 * 1000) + + try { + await service.clearStats(nonce) + expect.fail("should have thrown") + } catch (err) { + expect(err).toBeInstanceOf(StatsServiceError) + expect((err as StatsServiceError).code).toBe("STATS_SERVICE/clear/001") + } + + vi.useRealTimers() + }) + + it("should consume nonce after successful clear (one-time use)", async () => { + const events = [makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1" })] + await service.backfillFromHistory(events) + + const nonce = service.issueClearNonce() + await service.clearStats(nonce) + + // Retry with the same nonce → should fail + await expect(service.clearStats(nonce)).rejects.toThrow(StatsServiceError) + }) + }) + + // ── backfillFromHistory ────────────────────────────────────────────────── + + describe("backfillFromHistory", () => { + it("should append events and return the count of appended events", async () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-2" }), + makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3" }), + ] + + const count = await service.backfillFromHistory(events) + expect(count).toBe(3) + }) + + it("should set provenance to history-backfill for all events", async () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1", provenance: "live" }), + ] + + await service.backfillFromHistory(events) + + const query = makeQuery({ preset: "all" }) + const result = await service.exportStats(query, "json") + const jsonExport = result as { events: UsageEventV1[] } + + expect(jsonExport.events[0].provenance).toBe("history-backfill") + }) + + it("should return 0 for empty events array", async () => { + const count = await service.backfillFromHistory([]) + expect(count).toBe(0) + }) + + it("should deduplicate events with same idempotencyKey", async () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-1" }), // Same idempotencyKey + ] + + const count = await service.backfillFromHistory(events) + expect(count).toBe(1) + }) + + it("should swallow StatsStoreError and continue processing remaining events", async () => { + // First event is normal, second is deduped with the same idempotencyKey (returns false), + // third is normal + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1" }), + makeEvent({ eventId: "evt-2", idempotencyKey: "idem-1" }), // dedupe → false + makeEvent({ eventId: "evt-3", idempotencyKey: "idem-3" }), + ] + + const count = await service.backfillFromHistory(events) + // Deduped ones return false → count does not increment + expect(count).toBe(2) + }) + }) + + // ── isCapped ──────────────────────────────────────────────────────────── + + describe("isCapped", () => { + it("should return false for a fresh store", () => { + expect(service.isCapped()).toBe(false) + }) + + it("should return false after appending a small number of events", async () => { + const events = [ + makeEvent({ eventId: "evt-1", idempotencyKey: "idem-1" }), + ] + await service.backfillFromHistory(events) + + expect(service.isCapped()).toBe(false) + }) + }) + + // ── Error class ───────────────────────────────────────────────────────── + + describe("StatsServiceError", () => { + it("should format message with error code prefix", () => { + const err = new StatsServiceError( + "STATS_SERVICE/export/001", + "Unsupported export format: xml", + ) + + expect(err.message).toContain("[STATS_SERVICE/export/001]") + expect(err.message).toContain("Unsupported export format: xml") + expect(err.name).toBe("StatsServiceError") + }) + + it("should preserve cause when provided", () => { + const cause = new Error("root cause") + const err = new StatsServiceError( + "STATS_SERVICE/backfill/001", + "Backfill failed", + cause, + ) + + expect(err.cause).toBe(cause) + }) + }) +}) diff --git a/src/services/stats/__tests__/costRecalculation.spec.ts b/src/services/stats/__tests__/costRecalculation.spec.ts new file mode 100644 index 0000000000..d410f9ab2e --- /dev/null +++ b/src/services/stats/__tests__/costRecalculation.spec.ts @@ -0,0 +1,326 @@ +// src/services/stats/__tests__/costRecalculation.spec.ts +// +// Tests for Feature 1: Recalculate cost for old usage events at query time. + +import { describe, it, expect } from "vitest" + +import type { UsageEventV1 } from "@roo-code/types" + +import { getEffectiveCost, computeEventCost, lookupModelInfo } from "../costRecalculation" + +// ── Test Helpers ──────────────────────────────────────────────────────────── + +function makeEvent(overrides: Partial = {}): UsageEventV1 { + return { + schemaVersion: 1, + eventId: `evt-${Math.random().toString(36).slice(2)}`, + idempotencyKey: `idem-${Math.random().toString(36).slice(2)}`, + occurredAt: "2026-07-19T10:00:00.000Z", + timezoneOffsetMinutes: 540, + status: "completed", + attempt: 1, + taskId: "task-001", + provider: "anthropic", + model: "claude-sonnet-4-5", + mode: "code", + usage: { + inputTokens: { value: 1000, source: "provider" }, + outputTokens: { value: 500, source: "provider" }, + }, + semantics: { + cacheReadInInput: "excluded", + cacheWriteInInput: "excluded", + reasoningInOutput: "excluded", + }, + provenance: "live", + ...overrides, + } +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("costRecalculation", () => { + describe("lookupModelInfo", () => { + it("should find model info for a known Anthropic model", () => { + const info = lookupModelInfo("anthropic", "claude-sonnet-4-5") + expect(info).toBeDefined() + expect(info?.inputPrice).toBe(3.0) + expect(info?.outputPrice).toBe(15.0) + }) + + it("should find model info for a known OpenAI model", () => { + const info = lookupModelInfo("openai", "gpt-5.6-sol") + expect(info).toBeDefined() + expect(info?.inputPrice).toBe(5.0) + }) + + it("should resolve openai-codex models to openAiNativeModels pricing (non-zero)", () => { + // Regression test for Bug 2: openai-codex was mapped to openAiCodexModels + // which has all-zero prices. Now it maps to openAiNativeModels so users + // see the equivalent API cost. + const info = lookupModelInfo("openai-codex", "gpt-5.6-sol") + expect(info).toBeDefined() + expect(info?.inputPrice).toBe(5.0) + expect(info?.outputPrice).toBe(30.0) + }) + + it("should resolve qwen-code models to qwenCodeModels pricing (non-zero)", () => { + // Regression test for Bug 3: qwen-code models had all-zero prices. + // Now qwen3-coder-plus has inputPrice=$1.0/1M, outputPrice=$5.0/1M. + const info = lookupModelInfo("qwen-code", "qwen3-coder-plus") + expect(info).toBeDefined() + expect(info?.inputPrice).toBe(1.0) + expect(info?.outputPrice).toBe(5.0) + }) + + it("should return undefined for an unknown provider", () => { + const info = lookupModelInfo("unknown-provider", "some-model") + expect(info).toBeUndefined() + }) + + it("should return undefined for a model with no substring match in a known provider", () => { + const info = lookupModelInfo("anthropic", "zzz-nonexistent-xyz") + expect(info).toBeUndefined() + }) + + it("should match via substring for versioned model IDs", () => { + // "claude-sonnet-4-20250514" should match "claude-sonnet-4" family + const info = lookupModelInfo("anthropic", "claude-sonnet-4-20250514") + expect(info).toBeDefined() + }) + }) + + describe("computeEventCost", () => { + it("should return 0 when event already has a costUsd value", () => { + const event = makeEvent({ + usage: { + inputTokens: { value: 1000, source: "provider" }, + outputTokens: { value: 500, source: "provider" }, + costUsd: { value: 0.05, source: "provider" }, + }, + }) + // computeEventCost returns the stored cost when present + expect(computeEventCost(event)).toBe(0.05) + }) + + it("should compute cost for Anthropic event with missing costUsd", () => { + const event = makeEvent({ + provider: "anthropic", + model: "claude-sonnet-4-5", + usage: { + inputTokens: { value: 1_000_000, source: "provider" }, + outputTokens: { value: 0, source: "provider" }, + // costUsd missing + }, + }) + // Anthropic claude-sonnet-4-5: $3/1M input tokens + // 1M input tokens × $3/1M = $3.0 + expect(computeEventCost(event)).toBeCloseTo(3.0, 5) + }) + + it("should compute cost for OpenAI event with missing costUsd", () => { + const event = makeEvent({ + provider: "openai", + model: "gpt-5.6-sol", + usage: { + inputTokens: { value: 100_000, source: "provider" }, + outputTokens: { value: 0, source: "provider" }, + // costUsd missing + }, + }) + // OpenAI gpt-5.6-sol: $5/1M input tokens (below long-context threshold of 272K) + // 100K input tokens at $5/1M = $0.5 + expect(computeEventCost(event)).toBeCloseTo(0.5, 5) + }) + + it("should return 0 when model info is not available", () => { + const event = makeEvent({ + provider: "unknown-provider", + model: "unknown-model", + usage: { + inputTokens: { value: 1000, source: "provider" }, + outputTokens: { value: 500, source: "provider" }, + // costUsd missing + }, + }) + expect(computeEventCost(event)).toBe(0) + }) + + it("should return 0 when all token counts are zero", () => { + const event = makeEvent({ + provider: "anthropic", + model: "claude-sonnet-4-5", + usage: { + // All tokens zero/missing + }, + }) + expect(computeEventCost(event)).toBe(0) + }) + + it("should include cache costs for Anthropic-semantic providers", () => { + const event = makeEvent({ + provider: "anthropic", + model: "claude-sonnet-4-5", + usage: { + inputTokens: { value: 0, source: "provider" }, + outputTokens: { value: 0, source: "provider" }, + cacheWriteTokens: { value: 1_000_000, source: "provider" }, + cacheReadTokens: { value: 1_000_000, source: "provider" }, + // costUsd missing + }, + }) + // claude-sonnet-4-5: cacheWritesPrice=$3.75/1M, cacheReadsPrice=$0.30/1M + // 1M cache write × $3.75/1M + 1M cache read × $0.30/1M = $4.05 + expect(computeEventCost(event)).toBeCloseTo(4.05, 5) + }) + + it("should compute non-zero cost for openai-codex (ChatGPT Plus/Pro) event with missing costUsd", () => { + // Regression test for Bug 2: openai-codex events always showed $0.00 + // because openAiCodexModels has all-zero prices. + // Fix: costRecalculation.ts maps "openai-codex" → openAiNativeModels + // so users see the equivalent API cost. + const event = makeEvent({ + provider: "openai-codex", + model: "gpt-5.6-sol", + usage: { + inputTokens: { value: 100_000, source: "provider" }, + outputTokens: { value: 0, source: "provider" }, + // costUsd missing — simulates the old totalCost: 0 → falsy → undefined path + }, + }) + // openAiNativeModels["gpt-5.6-sol"]: inputPrice=$5.0/1M + // 100K input tokens × $5/1M = $0.5 + // This must NOT be 0 — that was the bug. + const cost = computeEventCost(event) + expect(cost).toBeGreaterThan(0) + expect(cost).toBeCloseTo(0.5, 5) + }) + + it("should compute non-zero cost for openai-codex with output tokens", () => { + const event = makeEvent({ + provider: "openai-codex", + model: "gpt-5.6-sol", + usage: { + // Use 100K each (200K total < 272K long-context threshold) + inputTokens: { value: 100_000, source: "provider" }, + outputTokens: { value: 100_000, source: "provider" }, + // costUsd missing + }, + }) + // openAiNativeModels["gpt-5.6-sol"]: inputPrice=$5.0/1M, outputPrice=$30.0/1M + // 100K input × $5/1M + 100K output × $30/1M = $0.5 + $3.0 = $3.5 + const cost = computeEventCost(event) + expect(cost).toBeGreaterThan(0) + expect(cost).toBeCloseTo(3.5, 5) + }) + + it("should compute non-zero cost for qwen-code event with missing costUsd", () => { + // Regression test for Bug 3: qwen-code models had all-zero prices. + // Now qwen3-coder-plus has inputPrice=$1.0/1M, outputPrice=$5.0/1M. + const event = makeEvent({ + provider: "qwen-code", + model: "qwen3-coder-plus", + usage: { + inputTokens: { value: 100_000, source: "provider" }, + outputTokens: { value: 0, source: "provider" }, + // costUsd missing + }, + }) + // qwenCodeModels["qwen3-coder-plus"]: inputPrice=$1.0/1M + // 100K input tokens × $1.0/1M = $0.1 + // This must NOT be 0 — that was the bug. + const cost = computeEventCost(event) + expect(cost).toBeGreaterThan(0) + expect(cost).toBeCloseTo(0.1, 5) + }) + + it("should compute non-zero cost for qwen-code with input + output tokens", () => { + const event = makeEvent({ + provider: "qwen-code", + model: "qwen3-coder-plus", + usage: { + inputTokens: { value: 100_000, source: "provider" }, + outputTokens: { value: 100_000, source: "provider" }, + // costUsd missing + }, + }) + // qwenCodeModels["qwen3-coder-plus"]: inputPrice=$1.0/1M, outputPrice=$5.0/1M + // 100K input × $1/1M + 100K output × $5/1M = $0.1 + $0.5 = $0.6 + const cost = computeEventCost(event) + expect(cost).toBeGreaterThan(0) + expect(cost).toBeCloseTo(0.6, 5) + }) + }) + + describe("getEffectiveCost", () => { + it("should return stored cost when present", () => { + const event = makeEvent({ + usage: { + inputTokens: { value: 1000, source: "provider" }, + costUsd: { value: 0.02, source: "provider" }, + }, + }) + expect(getEffectiveCost(event)).toBe(0.02) + }) + + it("should compute cost when costUsd is missing", () => { + const event = makeEvent({ + provider: "anthropic", + model: "claude-sonnet-4-5", + usage: { + inputTokens: { value: 1_000_000, source: "provider" }, + outputTokens: { value: 0, source: "provider" }, + // costUsd missing + }, + }) + expect(getEffectiveCost(event)).toBeCloseTo(3.0, 5) + }) + + it("should return 0 when costUsd is missing and model is unknown", () => { + const event = makeEvent({ + provider: "unknown-provider", + model: "unknown-model", + usage: { + inputTokens: { value: 1000, source: "provider" }, + // costUsd missing + }, + }) + expect(getEffectiveCost(event)).toBe(0) + }) + + it("should return 0 when costUsd is undefined (not just missing value)", () => { + const event = makeEvent({ + provider: "anthropic", + model: "claude-sonnet-4-5", + usage: { + inputTokens: { value: 1000, source: "provider" }, + // costUsd is undefined (not present in usage object) + }, + }) + // Should compute from pricing: 1000 × $3/1M = $0.003 + expect(getEffectiveCost(event)).toBeCloseTo(0.003, 5) + }) + + it("should compute non-zero cost for openai-codex event when costUsd is undefined", () => { + // Regression test for Bug 2: openai-codex provider hardcoded totalCost: 0, + // which UsageRecorder stored as costUsd: undefined (0 is falsy). + // getEffectiveCost must fall through to computeEventCost and return + // a non-zero value from openAiNativeModels pricing. + const event = makeEvent({ + provider: "openai-codex", + model: "gpt-5.6-sol", + usage: { + inputTokens: { value: 100_000, source: "provider" }, + outputTokens: { value: 0, source: "provider" }, + // costUsd is undefined — simulates the old totalCost: 0 → falsy → undefined path + }, + }) + // openAiNativeModels["gpt-5.6-sol"]: inputPrice=$5.0/1M + // 100K input tokens × $5/1M = $0.5 + // Must NOT be 0 — that was the bug. + const cost = getEffectiveCost(event) + expect(cost).toBeGreaterThan(0) + expect(cost).toBeCloseTo(0.5, 5) + }) + }) +}) diff --git a/src/services/stats/costRecalculation.ts b/src/services/stats/costRecalculation.ts new file mode 100644 index 0000000000..6f4b8b3a6a --- /dev/null +++ b/src/services/stats/costRecalculation.ts @@ -0,0 +1,189 @@ +// src/services/stats/costRecalculation.ts +// +// Feature 1: Recalculate cost for old usage events at query time. +// +// Problem: Old usage events have `costUsd: undefined` because the providers +// did not calculate `totalCost` at recording time. The NDJSON store is +// append-only, so we cannot modify existing events. +// +// Solution: Compute cost on-the-fly when `costUsd` is missing, using the +// model's pricing info from the provider's static model registry. +// +// Key constraint: This module NEVER modifies the NDJSON file. It only +// computes a derived cost value at query/display time. + +import type { ModelInfo, UsageEventV1 } from "@roo-code/types" + +import { + anthropicModels, + openAiNativeModels, + bedrockModels, + deepSeekModels, + fireworksModels, + friendliModels, + geminiModels, + mistralModels, + moonshotModels, + minimaxModels, + mimoModels, + qwenCodeModels, + sambaNovaModels, + vertexModels, + xaiModels, + internationalZAiModels, + mainlandZAiModels, + vscodeLlmModels, + opencodeGoModels, +} from "@roo-code/types" + +import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "../../shared/cost" + +// ── Provider → Model Registry Mapping ────────────────────────────────────── + +/** + * Maps a provider name (as stored in `UsageEventV1.provider`) to its static + * model registry. Only providers with a static, locally-known model registry + * are included here. Dynamic providers (openrouter, requesty, etc.) fetch + * models at runtime and cannot be resolved at query time without network + * access, so they are excluded — cost stays 0 for those events (per the + * task spec: "If pricing info is not available for the model, leave cost as 0"). + */ +const PROVIDER_MODEL_REGISTRIES: Record> = { + anthropic: anthropicModels, + openai: openAiNativeModels, + "openai-native": openAiNativeModels, + // openai-codex uses ChatGPT Plus/Pro subscription (no per-token billing), + // but we map to openAiNativeModels so users can see the equivalent API cost + // for comparison purposes. The actual charge is covered by the subscription. + "openai-codex": openAiNativeModels, + bedrock: bedrockModels, + deepseek: deepSeekModels, + fireworks: fireworksModels, + friendli: friendliModels, + gemini: geminiModels, + vertex: vertexModels, + mistral: mistralModels, + moonshot: moonshotModels, + minimax: minimaxModels, + mimo: mimoModels, + "qwen-code": qwenCodeModels, + sambanova: sambaNovaModels, + xai: xaiModels, + zai: { ...internationalZAiModels, ...mainlandZAiModels }, + "vscode-llm": vscodeLlmModels, + "opencode-go": opencodeGoModels, +} + +/** + * Providers whose usage semantics follow the Anthropic convention: + * `inputTokens` does NOT include cached tokens (cache reads + cache writes + * are reported separately and added to the total). + * + * All other providers follow the OpenAI convention where `inputTokens` + * already includes cached tokens. + */ +const ANTHROPIC_SEMANTIC_PROVIDERS = new Set(["anthropic", "bedrock", "vertex"]) + +// ── Model Info Lookup ──────────────────────────────────────────────────────── + +/** + * Looks up the {@link ModelInfo} for a given provider + model combination. + * + * Strategy: + * 1. Direct lookup in the provider's static registry by exact model ID. + * 2. If not found, attempt case-insensitive substring matching against + * known model IDs (handles versioned variants like + * "claude-sonnet-4-20250514" matching "claude-sonnet-4"). + * 3. If still not found, return `undefined` (cost stays 0). + * + * @param provider The provider name from the usage event. + * @param model The model ID from the usage event. + * @returns The matching ModelInfo, or undefined if not found. + */ +export function lookupModelInfo(provider: string, model: string): ModelInfo | undefined { + const registry = PROVIDER_MODEL_REGISTRIES[provider] + if (!registry) return undefined + + // 1. Exact match + if (model in registry) return registry[model] + + // 2. Case-insensitive substring match (longest known ID first for specificity) + const knownIds = Object.keys(registry) + const lowerModel = model.toLowerCase() + const sortedIds = [...knownIds].sort((a, b) => b.length - a.length) + for (const knownId of sortedIds) { + if (lowerModel.includes(knownId.toLowerCase())) { + return registry[knownId] + } + } + + // 3. Not found + return undefined +} + +// ── Cost Computation ───────────────────────────────────────────────────────── + +/** + * Computes the cost (in USD) for a single usage event using the model's + * pricing info. Returns 0 if: + * - The event already has a `costUsd` value (caller should use that instead). + * - The model info cannot be resolved for the provider/model combination. + * - The token counts are all zero. + * + * The function respects the event's inclusion semantics: + * - For Anthropic-semantic providers: `inputTokens` does NOT include cached + * tokens, so cache reads/writes are added to the total input. + * - For OpenAI-semantic providers: `inputTokens` already includes cached + * tokens, so the non-cached portion is computed before applying pricing. + * + * @param event The usage event to compute cost for. + * @returns The computed cost in USD, or 0 if it cannot be computed. + */ +export function computeEventCost(event: UsageEventV1): number { + // If the event already has a cost, the caller should use it directly. + // This function is only for computing MISSING costs. + if (event.usage.costUsd !== undefined && event.usage.costUsd.value > 0) { + return event.usage.costUsd.value + } + + const modelInfo = lookupModelInfo(event.provider, event.model) + if (!modelInfo) return 0 + + const inputTokens = event.usage.inputTokens?.value ?? 0 + const outputTokens = event.usage.outputTokens?.value ?? 0 + const cacheWriteTokens = event.usage.cacheWriteTokens?.value ?? 0 + const cacheReadTokens = event.usage.cacheReadTokens?.value ?? 0 + + // If there are no tokens at all, cost is 0. + if (inputTokens === 0 && outputTokens === 0 && cacheWriteTokens === 0 && cacheReadTokens === 0) { + return 0 + } + + const isAnthropicSemantic = ANTHROPIC_SEMANTIC_PROVIDERS.has(event.provider) + + let result + if (isAnthropicSemantic) { + result = calculateApiCostAnthropic(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) + } else { + result = calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) + } + + return result.totalCost +} + +/** + * Returns the effective cost for a usage event: the stored cost if present, + * or the computed cost if missing. + * + * This is the primary entry point for query-time cost resolution. It never + * modifies the event — it returns a derived number. + * + * @param event The usage event. + * @returns The effective cost in USD (stored or computed; 0 if unresolvable). + */ +export function getEffectiveCost(event: UsageEventV1): number { + if (event.usage.costUsd !== undefined) { + return event.usage.costUsd.value + } + return computeEventCost(event) +} diff --git a/src/services/stats/index.ts b/src/services/stats/index.ts new file mode 100644 index 0000000000..fd73693792 --- /dev/null +++ b/src/services/stats/index.ts @@ -0,0 +1,25 @@ +// ── Stats Service Barrel Export ───────────────────────────────────────────── +// +// Re-exports the public APIs of UsageEventStore, UsageAggregator, UsageStatsService, and UsageRecorder. +// Task instrumentation in Commit 3 and handlers in Commit 4 import this module. + +export { UsageEventStore, StatsStoreError } from "./UsageEventStore" +export type { + UsageStatsManifest, + QuarantineReportEntry, + StatsStoreErrorCode, +} from "./UsageEventStore" + +export { UsageAggregator } from "./UsageAggregator" + +export { UsageStatsService, StatsServiceError } from "./UsageStatsService" +export type { + ExportFormat, + JsonExport, + StatsServiceErrorCode, +} from "./UsageStatsService" + +export { UsageRecorder } from "./UsageRecorder" +export type { UsageRecordingContext, UsageEventSink } from "./UsageRecorder" + +export { getEffectiveCost, computeEventCost, lookupModelInfo } from "./costRecalculation" diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 0521499dbb..424f0b19ea 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -16,6 +16,7 @@ import HistoryView from "./components/history/HistoryView" import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView" import WelcomeView from "./components/welcome/WelcomeViewProvider" import { MarketplaceView } from "./components/marketplace/MarketplaceView" +import DashboardView from "./components/dashboard/DashboardView" import { CheckpointRestoreDialog } from "./components/chat/CheckpointRestoreDialog" import { DeleteMessageDialog, EditMessageDialog } from "./components/chat/MessageModificationConfirmationDialog" import ErrorBoundary from "./components/ErrorBoundary" @@ -23,7 +24,7 @@ import { useAddNonInteractiveClickListener } from "./components/ui/hooks/useNonI import { TooltipProvider } from "./components/ui/tooltip" import { STANDARD_TOOLTIP_DELAY } from "./components/ui/standard-tooltip" -type Tab = "settings" | "history" | "chat" | "marketplace" +type Tab = "settings" | "history" | "chat" | "marketplace" | "dashboard" interface DeleteMessageDialogState { isOpen: boolean @@ -48,6 +49,7 @@ const tabsByMessageAction: Partial { @@ -246,6 +248,7 @@ const App = () => { targetTab={currentMarketplaceTab as "mcp" | "mode" | undefined} /> )} + {tab === "dashboard" && switchTab("chat")} />} ( +
+ {label} + + + {value} + + + {unknownCount !== undefined && unknownCount > 0 && ( + + ({unknownCount} uncertain) + + )} +
+)) + +// ── DashboardSummary ──────────────────────────────────────────────────────── + +interface DashboardSummaryProps { + totals: StatsBucket +} + +const DashboardSummary = memo(({ totals }: DashboardSummaryProps) => { + const { t } = useAppTranslation() + + const cacheTotal = totals.cacheReadTokens + totals.cacheWriteTokens + + return ( +
+ + + + + +
+ ) +}) + +export default DashboardSummary diff --git a/webview-ui/src/components/dashboard/DashboardView.tsx b/webview-ui/src/components/dashboard/DashboardView.tsx new file mode 100644 index 0000000000..6d094fd1d7 --- /dev/null +++ b/webview-ui/src/components/dashboard/DashboardView.tsx @@ -0,0 +1,861 @@ +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" +import { ArrowLeft, Download, Trash2, RefreshCw } from "lucide-react" + +import type { ExtensionMessage, StatsQuery, StatsSnapshot, SessionSummary, SessionDetail } from "@roo-code/types" + +import { vscode } from "@/utils/vscode" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { formatCompact, formatCost } from "@/utils/formatNumber" + +import { Button, StandardTooltip } from "@/components/ui" +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from "@/components/ui/alert-dialog" + +import { Tab, TabHeader, TabContent } from "../common/Tab" +import DashboardSummary from "./DashboardSummary" +import SessionList from "./SessionList" +import UsageHeatmap from "../stats/UsageHeatmap" + +// ── Types ─────────────────────────────────────────────────────────────────── + +// Dashboard range presets. "custom" is a local-only UI state: when selected, +// the query is sent with explicit from/to ISO strings and no `preset` field +// (the backend StatsQuery schema only allows today/7d/30d/all for `preset`). +type DashboardPreset = "today" | "7d" | "30d" | "custom" | "all" +type DashboardGroupBy = "model" | "provider" | "mode" + +interface DashboardViewProps { + onDone: () => void +} + +// ── DashboardView ─────────────────────────────────────────────────────────── + +const DashboardView = memo(({ onDone }: DashboardViewProps) => { + const { t } = useAppTranslation() + + const [preset, setPreset] = useState("today") + const [groupBy, setGroupBy] = useState("model") + const [snapshot, setSnapshot] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showClearDialog, setShowClearDialog] = useState(false) + const [clearNonce, setClearNonce] = useState(null) + + // Custom range date inputs (YYYY-MM-DD). Only used when preset === "custom". + // Default to yesterday~today so the inputs are never empty on first selection. + const toLocalDateString = useCallback((d: Date) => { + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, "0") + const day = String(d.getDate()).padStart(2, "0") + return `${year}-${month}-${day}` + }, []) + + const defaultDateRange = useMemo(() => { + const now = new Date() + const yesterday = new Date(now) + yesterday.setDate(now.getDate() - 1) + return { from: toLocalDateString(yesterday), to: toLocalDateString(now) } + }, [toLocalDateString]) + + const [customFrom, setCustomFrom] = useState(defaultDateRange.from) + const [customTo, setCustomTo] = useState(defaultDateRange.to) + + // Track the latest request to ignore stale responses + const latestRequestIdRef = useRef("") + + // ── Sessions state (Commit 3) ────────────────────────────────────────── + // Sessions are fetched independently from the stats snapshot so that the + // session list can update without re-fetching the full aggregation. The + // session request reuses the same `buildQuery()` time range so the two + // views stay consistent. + const [sessions, setSessions] = useState([]) + const [sessionsLoading, setSessionsLoading] = useState(false) + const [sessionsError, setSessionsError] = useState(null) + const latestSessionsRequestIdRef = useRef("") + + // ── Session detail state (Commit 4) ──────────────────────────────────── + // Only one session is expanded at a time (accordion pattern). The detail + // is fetched on first expansion via `getDashboardSessionDetail` and cached + // in `sessionDetails` so re-expanding does not refetch. The + // `latestSessionDetailRequestIdRef` correlates the IPC response so stale + // responses (e.g. from a previous expansion) are ignored. + const [expandedTaskId, setExpandedTaskId] = useState(undefined) + const [sessionDetails, setSessionDetails] = useState>({}) + const [sessionDetailErrors, setSessionDetailErrors] = useState>({}) + const [sessionDetailLoading, setSessionDetailLoading] = useState>(new Set()) + const latestSessionDetailRequestIdRef = useRef("") + + // ── Auto-refresh debounce timer (Commit 4) ───────────────────────────── + // The `usageStatsChanged` listener uses a ref-based timer so the cleanup + // function returned from the event handler does not get mistaken for a + // React effect cleanup. The previous implementation returned + // `clearTimeout` from inside the `MessageEvent` handler, which React's + // synthetic event system treated as an effect cleanup — causing the timer + // to be cleared immediately on the next render cycle. The ref-based + // approach decouples the debounce lifecycle from the event handler return + // value. + const refreshTimerRef = useRef | null>(null) + + // ── Query construction ────────────────────────────────────────────────── + + const timezone = useMemo(() => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" + } catch { + return "UTC" + } + }, []) + + const buildQuery = useCallback( + ( + currentPreset: DashboardPreset, + currentGroupBy: DashboardGroupBy, + fromOverride?: string, + toOverride?: string, + ): StatsQuery => { + const now = new Date() + let from: string | undefined + let to: string | undefined + // The backend preset enum is ["today", "7d", "30d", "all"]. + // For "custom" we omit preset and send explicit from/to ISO strings. + let queryPreset: StatsQuery["preset"] + + if (currentPreset === "today") { + const startOfDay = new Date(now) + startOfDay.setHours(0, 0, 0, 0) + from = startOfDay.toISOString() + queryPreset = "today" + } else if (currentPreset === "7d") { + const start = new Date(now) + start.setDate(start.getDate() - 7) + from = start.toISOString() + queryPreset = "7d" + } else if (currentPreset === "30d") { + const start = new Date(now) + start.setDate(start.getDate() - 30) + from = start.toISOString() + queryPreset = "30d" + } else if (currentPreset === "custom") { + // Convert YYYY-MM-DD inputs to ISO start-of-day / end-of-day. + // fromOverride/toOverride let a fresh input value be used + // immediately without waiting for state to flush. + const fromStr = fromOverride ?? customFrom + const toStr = toOverride ?? customTo + if (fromStr) { + from = new Date(`${fromStr}T00:00:00`).toISOString() + } + if (toStr) { + to = new Date(`${toStr}T23:59:59.999`).toISOString() + } + // No preset for custom range + } + // "all" → no from/to, preset "all" + else if (currentPreset === "all") { + queryPreset = "all" + } + + return { + preset: queryPreset, + from, + to, + timezone, + groupBy: ( + [currentGroupBy] as Array< + "day" | "week" | "month" | "provider" | "model" | "mode" | "status" | "source" + > + ).filter((v, i, a) => a.indexOf(v) === i), + includeCancelled: false, + } + }, + [timezone, customFrom, customTo], + ) + + // ── Fetch statistics ───────────────────────────────────────────────────── + + const fetchStats = useCallback( + ( + currentPreset: DashboardPreset, + currentGroupBy: DashboardGroupBy, + fromOverride?: string, + toOverride?: string, + ) => { + const requestId = `dashboard-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + latestRequestIdRef.current = requestId + setLoading(true) + setError(null) + + const query = buildQuery(currentPreset, currentGroupBy, fromOverride, toOverride) + vscode.postMessage({ + type: "getUsageStats", + requestId, + usageStatsQuery: query, + }) + }, + [buildQuery], + ) + + // ── Fetch sessions (Commit 3) ────────────────────────────────────────── + // Sends `getDashboardSessions` with the same time-range query as the + // stats fetch. The response is correlated via `latestSessionsRequestIdRef` + // to ignore stale results. + const fetchSessions = useCallback( + ( + currentPreset: DashboardPreset, + currentGroupBy: DashboardGroupBy, + fromOverride?: string, + toOverride?: string, + ) => { + const requestId = `dashboard-sessions-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + latestSessionsRequestIdRef.current = requestId + setSessionsLoading(true) + setSessionsError(null) + + const query = buildQuery(currentPreset, currentGroupBy, fromOverride, toOverride) + vscode.postMessage({ + type: "getDashboardSessions", + requestId, + usageStatsQuery: query, + }) + }, + [buildQuery], + ) + + // ── Fetch session detail (Commit 4) ─────────────────────────────────── + // Sends `getDashboardSessionDetail` with the taskId. The response is + // correlated via `latestSessionDetailRequestIdRef` to ignore stale + // results. The detail is cached in `sessionDetails` so re-expanding a + // row does not trigger a refetch. + const fetchSessionDetail = useCallback((taskId: string) => { + const requestId = `dashboard-session-detail-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + latestSessionDetailRequestIdRef.current = requestId + + // Mark this task as loading. Using a new Set instance so React + // detects the state change. + setSessionDetailLoading((prev) => { + const next = new Set(prev) + next.add(taskId) + return next + }) + // Clear any previous error for this task. + setSessionDetailErrors((prev) => { + if (prev[taskId] === undefined) return prev + const next = { ...prev } + next[taskId] = null + return next + }) + + vscode.postMessage({ + type: "getDashboardSessionDetail", + requestId, + taskId, + }) + }, []) + + // ── Toggle session expansion (Commit 4) ─────────────────────────────── + // Accordion pattern: clicking a row toggles its expansion. Clicking + // another row closes the previous one. The detail is fetched on first + // expansion; if already cached, the cached value is shown immediately. + const handleToggleSession = useCallback( + (taskId: string) => { + setExpandedTaskId((current) => { + // Toggling the already-expanded row collapses it. + if (current === taskId) return undefined + + // Expanding a new row: fetch detail if not already cached. + // We check the cache outside the state setter to avoid + // stale-closure issues with `sessionDetails`. + if (sessionDetails[taskId] === undefined && !sessionDetailLoading.has(taskId)) { + fetchSessionDetail(taskId) + } + return taskId + }) + }, + [sessionDetails, sessionDetailLoading, fetchSessionDetail], + ) + + // Initial fetch on mount + useEffect(() => { + fetchStats(preset, groupBy) + fetchSessions(preset, groupBy) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Refetch when preset or groupBy changes + const handlePresetChange = useCallback( + (newPreset: DashboardPreset) => { + setPreset(newPreset) + // For custom, only fetch if both dates are present + if (newPreset === "custom" && (!customFrom || !customTo)) { + return + } + fetchStats(newPreset, groupBy) + fetchSessions(newPreset, groupBy) + }, + [groupBy, fetchStats, fetchSessions, customFrom, customTo], + ) + + const handleGroupByChange = useCallback( + (newGroupBy: DashboardGroupBy) => { + setGroupBy(newGroupBy) + fetchStats(preset, newGroupBy) + fetchSessions(preset, newGroupBy) + }, + [preset, fetchStats, fetchSessions], + ) + + const handleRefresh = useCallback(() => { + fetchStats(preset, groupBy) + fetchSessions(preset, groupBy) + }, [preset, groupBy, fetchStats, fetchSessions]) + + // Apply a custom date range: triggered when both inputs are filled and + // the user wants to run the query (e.g. on "To" date change, or explicitly). + const handleApplyCustomRange = useCallback(() => { + if (!customFrom || !customTo) return + fetchStats("custom", groupBy, customFrom, customTo) + fetchSessions("custom", groupBy, customFrom, customTo) + }, [customFrom, customTo, groupBy, fetchStats, fetchSessions]) + + + // ── Listen for responses ──────────────────────────────────────────────── + + useEffect(() => { + const handleMessage = (e: MessageEvent) => { + const message: ExtensionMessage = e.data + + if (message.type === "getUsageStatsResponse") { + // Only accept the latest request's response + if (message.requestId !== latestRequestIdRef.current) return + + if (message.usageStatsSnapshot) { + setSnapshot(message.usageStatsSnapshot) + setLoading(false) + setError(null) + } else { + setError(t("dashboard:states.error")) + setLoading(false) + } + } + + if (message.type === "usageStatsChanged") { + // Data changed externally — refetch both stats and sessions with + // a 250ms debounce. The timer is stored in a ref (not returned as + // a cleanup) so React's synthetic event system does not mistake it + // for an effect cleanup and clear it on the next render cycle. + // Multiple `usageStatsChanged` events within the debounce window + // coalesce into a single refetch. + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current) + } + refreshTimerRef.current = setTimeout(() => { + fetchStats(preset, groupBy) + fetchSessions(preset, groupBy) + refreshTimerRef.current = null + }, 250) + // Do NOT return a cleanup here — the ref-based timer is cleared + // above on the next event and in the effect cleanup below. + } + + if (message.type === "dashboardSessionsResponse") { + // Only accept the latest sessions request's response + if (message.requestId !== latestSessionsRequestIdRef.current) return + + if (message.dashboardSessions) { + setSessions(message.dashboardSessions) + setSessionsLoading(false) + setSessionsError(null) + } else { + setSessionsError(message.error || t("dashboard:states.error")) + setSessionsLoading(false) + } + } + + if (message.type === "dashboardSessionDetailResponse") { + // Only accept the latest session detail request's response + if (message.requestId !== latestSessionDetailRequestIdRef.current) return + + // ExtensionMessage does not carry `taskId` for this response type, + // so we correlate via the currently expanded task. Because only + // one session is expanded at a time (accordion pattern) and the + // request is only sent when expanding, the expanded task is the + // one whose detail we are receiving. + const taskId = expandedTaskId + if (!taskId) return + + // Clear loading state for this task + setSessionDetailLoading((prev) => { + if (!prev.has(taskId)) return prev + const next = new Set(prev) + next.delete(taskId) + return next + }) + + // Capture the detail and error into locals so TypeScript can + // narrow the type before the deferred setState callbacks. Without + // this, `message.dashboardSessionDetail` would be + // `SessionDetail | null | undefined` inside the closure, which is + // not assignable to `Record`. + const detail = message.dashboardSessionDetail ?? null + const detailError = message.error || t("dashboard:states.error") + + setSessionDetails((prev) => ({ + ...prev, + [taskId]: detail, + })) + setSessionDetailErrors((prev) => ({ + ...prev, + [taskId]: detail ? null : detailError, + })) + } + + if (message.type === "requestClearNonceResponse") { + // Host issues the nonce; store it and open the confirm dialog. + // If the host returned null/error, surface it without opening the dialog. + if (message.clearNonce) { + setClearNonce(message.clearNonce) + setShowClearDialog(true) + } else { + setError(message.error || t("dashboard:states.error")) + setShowClearDialog(false) + setClearNonce(null) + } + } + + if (message.type === "clearUsageStatsResponse") { + if (message.clearUsageStatsResult?.success) { + setShowClearDialog(false) + setClearNonce(null) + fetchStats(preset, groupBy) + fetchSessions(preset, groupBy) + } else { + setError(message.clearUsageStatsResult?.error || t("dashboard:states.error")) + setShowClearDialog(false) + setClearNonce(null) + } + } + + if (message.type === "exportUsageStatsResponse") { + // Host handles the save dialog; nothing to do in webview + // unless there's an error + if (message.exportUsageStatsResult?.error) { + setError(message.exportUsageStatsResult.error) + } + } + } + + window.addEventListener("message", handleMessage) + return () => { + window.removeEventListener("message", handleMessage) + // Clear any pending debounce timer so a refetch does not fire + // after the component unmounts or the effect re-runs. + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current) + refreshTimerRef.current = null + } + } + }, [t, preset, groupBy, fetchStats, fetchSessions, fetchSessionDetail, expandedTaskId]) + + // ── Export ─────────────────────────────────────────────────────────────── + + const handleExport = useCallback( + (format: "csv") => { + const requestId = `dashboard-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const query = buildQuery(preset, groupBy) + vscode.postMessage({ + type: "exportUsageStats", + requestId, + usageStatsQuery: query, + exportUsageStatsFormat: format, + }) + }, + [preset, groupBy, buildQuery], + ) + + // ── Clear ──────────────────────────────────────────────────────────────── + + const handleClearRequest = useCallback(() => { + // Ask the host to issue a clear nonce. The host-generated nonce is + // returned via `requestClearNonceResponse` and stored in `clearNonce`. + const requestId = `dashboard-clear-nonce-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + vscode.postMessage({ + type: "requestClearNonce", + requestId, + }) + }, []) + + const handleClearConfirm = useCallback(() => { + if (!clearNonce) return + vscode.postMessage({ + type: "clearUsageStats", + requestId: clearNonce, + clearUsageStatsNonce: clearNonce, + }) + }, [clearNonce]) + + // ── Derived data ───────────────────────────────────────────────────────── + + const buckets = useMemo(() => snapshot?.buckets ?? [], [snapshot]) + const totals = useMemo( + () => + snapshot?.totals ?? { + key: {}, + events: 0, + completedCalls: 0, + failedCalls: 0, + cancelledCalls: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + costUsd: 0, + unknownEventCount: 0, + }, + [snapshot], + ) + + const hasData = totals.events > 0 + + // ── Render ─────────────────────────────────────────────────────────────── + + return ( + + +
+
+ +

{t("dashboard:title")}

+
+
+ + + + + + + + + +
+
+ + {/* Range selector */} +
+ {(["today", "7d", "30d", "custom", "all"] as DashboardPreset[]).map((p) => ( + + ))} + + {/* Custom date range inputs — shown only when "custom" is active */} + {preset === "custom" && ( +
+ + setCustomFrom(e.target.value)} + className="rounded border border-vscode-panel-border bg-vscode-input-background px-1.5 py-0.5 text-xs text-vscode-input-foreground" + data-testid="dashboard-custom-from" + /> + + setCustomTo(e.target.value)} + className="rounded border border-vscode-panel-border bg-vscode-input-background px-1.5 py-0.5 text-xs text-vscode-input-foreground" + data-testid="dashboard-custom-to" + /> + +
+ )} +
+
+ + + {/* Loading state */} + {loading && ( +
+ + + {t("dashboard:states.loading")} + +
+ )} + + {/* Error state */} + {!loading && error && ( +
+ {error} + +
+ )} + + {/* Empty state */} + {!loading && !error && !hasData && ( +
+ {t("dashboard:states.empty")} + + {t("dashboard:states.emptyHint")} + +
+ )} + + {/* Data display */} + {!loading && !error && hasData && ( + <> + {/* Summary cards */} + + + {/* Heatmap */} + + + {/* Breakdown table */} +
+
+

+ {t("dashboard:breakdown.title")} +

+
+ {(["model", "provider", "mode"] as DashboardGroupBy[]).map((g) => ( + + ))} +
+
+ + {/* Responsive table wrapper */} +
+ + + + + + + + + + + + + + + + {buckets.map((bucket, index) => { + const keyValue = bucket.key?.[groupBy] ?? t("dashboard:breakdown.unknown") + return ( + + + + + + + + + + + + ) + })} + +
+ {t(`dashboard:breakdown.${groupBy}`)} + + {t("dashboard:breakdown.events")} + + {t("dashboard:breakdown.inputTokens")} + + {t("dashboard:breakdown.outputTokens")} + + {t("dashboard:breakdown.cacheReadTokens")} + + {t("dashboard:breakdown.cacheWriteTokens")} + + {t("dashboard:breakdown.reasoningTokens")} + + {t("dashboard:breakdown.totalTokens")} + + {t("dashboard:breakdown.costUsd")} +
+ {String(keyValue)} + + {bucket.events} + + {formatCompact(bucket.inputTokens)} + + {formatCompact(bucket.outputTokens)} + + {formatCompact(bucket.cacheReadTokens)} + + {formatCompact(bucket.cacheWriteTokens)} + + {formatCompact(bucket.reasoningTokens)} + + {formatCompact(bucket.totalTokens)} + + {formatCost(bucket.costUsd)} +
+
+
+ + {/* Sessions list (Commit 3) */} + {sessionsLoading ? ( +
+ + + {t("dashboard:states.loading")} + +
+ ) : sessionsError ? ( +
+ {sessionsError} +
+ ) : ( + + )} + + {/* Data coverage */} + {snapshot?.coverage && ( +
+ + {t("dashboard:coverage.title")} + + {snapshot.coverage.firstEventAt && ( + + {t("dashboard:coverage.liveFrom")}:{" "} + {new Date(snapshot.coverage.firstEventAt).toLocaleString()} + + )} + {snapshot.coverage.lastEventAt && ( + + {t("dashboard:coverage.lastUpdated")}:{" "} + {new Date(snapshot.coverage.lastEventAt).toLocaleString()} + + )} + {snapshot.coverage.backfilledEventCount > 0 && ( + + {t("dashboard:coverage.backfilledEvents")}:{" "} + {snapshot.coverage.backfilledEventCount} + + )} + {snapshot.coverage.recordingPaused && ( + + {t("dashboard:coverage.paused")} + + )} +
+ )} + + )} +
+ + {/* Clear confirmation dialog */} + + + + {t("dashboard:clearDialog.title")} + {t("dashboard:clearDialog.description")} + + + + {t("dashboard:clearDialog.cancel")} + + + {t("dashboard:clearDialog.confirm")} + + + + +
+ ) +}) + +export default DashboardView diff --git a/webview-ui/src/components/dashboard/SessionDetail.tsx b/webview-ui/src/components/dashboard/SessionDetail.tsx new file mode 100644 index 0000000000..f300817602 --- /dev/null +++ b/webview-ui/src/components/dashboard/SessionDetail.tsx @@ -0,0 +1,240 @@ +import React, { memo, useMemo } from "react" + +import type { SessionDetail as SessionDetailType, APICallRecord } from "@roo-code/types" + +import { useAppTranslation } from "@/i18n/TranslationContext" +import { formatCompact, formatCost } from "@/utils/formatNumber" + +// ── Time formatting ────────────────────────────────────────────────────────── + +/** + * Formats an epoch millisecond timestamp as HH:MM (24-hour, local time). + * Example: 1721404320000 -> "14:32" + * + * Uses the user's local timezone so the displayed time matches what they + * would see in their task history. + */ +function formatTime(timestamp: number): string { + if (!timestamp) return "--:--" + try { + const date = new Date(timestamp) + const hours = String(date.getHours()).padStart(2, "0") + const minutes = String(date.getMinutes()).padStart(2, "0") + return `${hours}:${minutes}` + } catch { + return "--:--" + } +} + +// ── Status icon ────────────────────────────────────────────────────────────── + +/** + * Renders a status icon for an API call. + * - completed: ✅ + * - failed: ❌ + * - cancelled: 🔄 + * + * The icon is paired with an `aria-label` and a `title` so screen readers and + * tooltips convey the status without relying on the emoji alone. + */ +function StatusIcon({ status }: { status: APICallRecord["status"] }) { + const { t } = useAppTranslation() + const icon = status === "completed" ? "✅" : status === "failed" ? "❌" : "🔄" + const label = t(`dashboard:sessionDetail.status`) + return ( + + {icon} + + ) +} + +// ── API call list ──────────────────────────────────────────────────────────── + +interface APICallListProps { + apiCalls: APICallRecord[] +} + +/** + * Renders the per-API-call table for an expanded session. + * + * Columns: # (index), Mode, Time, Input Tokens, Output Tokens, Cost, Status, Model. + * The table is wrapped in an `overflow-x-auto` container so it remains usable + * on narrow viewports without breaking the dashboard layout. + */ +const APICallList = memo(({ apiCalls }: APICallListProps) => { + const { t } = useAppTranslation() + + if (apiCalls.length === 0) { + return ( +
+ {t("dashboard:sessionDetail.noApiCalls")} +
+ ) + } + + return ( +
+ + + + + + + + + + + + + + + {apiCalls.map((call) => ( + + + + + + + + + + + ))} + +
+ # + + {t("dashboard:sessionDetail.mode")} + + {t("dashboard:sessionDetail.time")} + + {t("dashboard:sessionDetail.input")} + + {t("dashboard:sessionDetail.output")} + + {t("dashboard:sessionDetail.cost")} + + {t("dashboard:sessionDetail.status")} + + {t("dashboard:sessionDetail.model")} +
+ {call.index} + + {call.mode || "—"} + + {formatTime(call.timestamp)} + + {formatCompact(call.inputTokens)} + + {formatCompact(call.outputTokens)} + + {formatCost(call.costUsd)} + + + + {call.model} +
+
+ ) +}) + +APICallList.displayName = "APICallList" + +// ── SessionDetail ──────────────────────────────────────────────────────────── + +interface SessionDetailProps { + /** The full session detail including per-API-call records. */ + detail: SessionDetailType +} + +/** + * Renders the expanded session detail: a summary header (totals, model, + * mode, call count) followed by the per-API-call table. + * + * The summary header reuses the same fields as {@link SessionSummary} so the + * expanded view is consistent with the collapsed row. The API call list is + * rendered by {@link APICallList}. + */ +const SessionDetail = memo(({ detail }: SessionDetailProps) => { + const { t } = useAppTranslation() + + // Derive summary fields for the header. SessionSummary only carries a + // combined `totalTokens`, so input/output totals are aggregated from the + // per-call records to give the user a meaningful split at a glance. + const { totalInputTokens, totalOutputTokens } = useMemo(() => { + let input = 0 + let output = 0 + for (const call of detail.apiCalls) { + input += call.inputTokens + output += call.outputTokens + } + return { totalInputTokens: input, totalOutputTokens: output } + }, [detail.apiCalls]) + + // A session may use multiple models/modes (e.g. orchestrator-crow + // delegating to code, debug, ask). Prefer the full `models`/`modes` + // arrays when present and non-empty, falling back to the legacy + // single-value fields for older payloads. + const modelDisplay = + detail.models && detail.models.length > 0 ? detail.models.join(", ") : detail.model || "—" + const modeDisplay = + detail.modes && detail.modes.length > 0 ? detail.modes.join(", ") : detail.mode || "—" + + const summaryItems = useMemo( + () => [ + { label: t("dashboard:sessionDetail.input"), value: formatCompact(totalInputTokens) }, + { label: t("dashboard:sessionDetail.output"), value: formatCompact(totalOutputTokens) }, + { label: t("dashboard:sessionDetail.cost"), value: formatCost(detail.totalCost) }, + { label: t("dashboard:sessionDetail.model"), value: modelDisplay }, + { label: t("dashboard:sessionDetail.mode"), value: modeDisplay }, + ], + [detail, t, totalInputTokens, totalOutputTokens, modelDisplay, modeDisplay], + ) + + return ( +
+ {/* Summary header */} +
+ + {t("dashboard:sessionDetail.summary")} + +
+ {summaryItems.map((item, i) => ( + + {item.label}: + {item.value} + + ))} +
+
+ + {/* API calls section */} +
+ + {t("dashboard:sessionDetail.apiCalls")} + + +
+
+ ) +}) + +SessionDetail.displayName = "SessionDetail" + +export default SessionDetail diff --git a/webview-ui/src/components/dashboard/SessionList.tsx b/webview-ui/src/components/dashboard/SessionList.tsx new file mode 100644 index 0000000000..16a1e87ac2 --- /dev/null +++ b/webview-ui/src/components/dashboard/SessionList.tsx @@ -0,0 +1,241 @@ +import React, { memo, useCallback } from "react" +import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react" +import i18next from "i18next" + +import type { SessionSummary, SessionDetail as SessionDetailType } from "@roo-code/types" + +import { useAppTranslation } from "@/i18n/TranslationContext" +import { formatCompact, formatCost } from "@/utils/formatNumber" + +import SessionDetail from "./SessionDetail" + +// ── Relative time formatting ──────────────────────────────────────────────── + +/** + * Formats a timestamp as a relative time string (e.g. "3 min ago", + * "1 hr ago", "yesterday"). Falls back to a localized absolute date for + * timestamps older than a week. + * + * Uses i18n keys from the `dashboard:time.*` namespace so the phrasing + * is translated for each locale. The absolute-date fallback uses + * `toLocaleDateString()` which respects the user's locale. + */ +function formatRelativeTime(timestamp: number): string { + const now = Date.now() + const diffMs = now - timestamp + const diffSec = Math.floor(diffMs / 1000) + const diffMin = Math.floor(diffSec / 60) + const diffHr = Math.floor(diffMin / 60) + const diffDay = Math.floor(diffHr / 24) + + if (diffSec < 60) return i18next.t("dashboard:time.justNow") + if (diffMin < 60) return i18next.t("dashboard:time.minutesAgo", { count: diffMin }) + if (diffHr < 24) return i18next.t("dashboard:time.hoursAgo", { count: diffHr }) + if (diffDay === 1) return i18next.t("dashboard:time.yesterday") + if (diffDay < 7) return i18next.t("dashboard:time.daysAgo", { count: diffDay }) + + // Older than a week: show absolute date. + return new Date(timestamp).toLocaleDateString() +} + +// ── Session row ────────────────────────────────────────────────────────────── + +/** + * The loading state for a session row whose detail is being fetched. + * Rendered in place of {@link SessionDetail} while the IPC request is in + * flight so the user gets immediate feedback that their click was registered. + */ +const SessionDetailLoading = memo(() => { + const { t } = useAppTranslation() + return ( +
+ + + {t("dashboard:states.loading")} + +
+ ) +}) + +SessionDetailLoading.displayName = "SessionDetailLoading" + +/** + * The error state for a session row whose detail fetch failed. Rendered in + * place of {@link SessionDetail} so the user can see the error inline and + * try expanding another row. + */ +const SessionDetailError = memo(({ error }: { error: string }) => { + return ( +
+ {error} +
+ ) +}) + +SessionDetailError.displayName = "SessionDetailError" + +interface SessionRowProps { + session: SessionSummary + /** Whether this row is currently expanded. */ + isExpanded: boolean + /** The loaded detail for this session, or undefined if not loaded/failed. */ + detail?: SessionDetailType | null + /** The error message if the detail fetch failed, or undefined. */ + detailError?: string | null + /** Whether the detail fetch is currently in flight. */ + detailLoading: boolean + /** Called when the user clicks the row to toggle expansion. */ + onToggle: (taskId: string) => void +} + +const SessionRow = memo( + ({ session, isExpanded, detail, detailError, detailLoading, onToggle }: SessionRowProps) => { + const { t } = useAppTranslation() + + const handleClick = useCallback(() => { + onToggle(session.taskId) + }, [onToggle, session.taskId]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + onToggle(session.taskId) + } + }, + [onToggle, session.taskId], + ) + + return ( +
+
+
+ {isExpanded ? ( + + ) : ( + + )} +
+ + {session.title} + + + {formatRelativeTime(session.timestamp)} + {" \u00b7 "} + {session.models && session.models.length > 0 + ? session.models.join(", ") + : session.model} + {" \u00b7 "} + {session.provider} + +
+
+
+ + {formatCompact(session.totalTokens)} + + + {formatCost(session.totalCost)} + {" \u00b7 "} + {t("dashboard:sessions.callCount", { count: session.callCount })} + +
+
+ {isExpanded && ( + <> + {detailLoading ? ( + + ) : detailError ? ( + + ) : detail ? ( + + ) : null} + + )} +
+ ) + }, +) + +SessionRow.displayName = "SessionRow" + +// ── SessionList ───────────────────────────────────────────────────────────── + +interface SessionListProps { + sessions: SessionSummary[] + /** The taskId of the currently expanded session, or undefined if none. */ + expandedTaskId?: string + /** Map of taskId -> loaded session detail (only populated for expanded rows). */ + sessionDetails: Record + /** Map of taskId -> detail fetch error message (only populated for failed fetches). */ + sessionDetailErrors: Record + /** Set of taskIds whose detail is currently being fetched. */ + sessionDetailLoading: Set + /** Called when the user clicks a session row to toggle its expansion. */ + onToggleSession: (taskId: string) => void +} + +const SessionList = memo( + ({ + sessions, + expandedTaskId, + sessionDetails, + sessionDetailErrors, + sessionDetailLoading, + onToggleSession, + }: SessionListProps) => { + const { t } = useAppTranslation() + + return ( +
+
+

+ {t("dashboard:sessions.title")} +

+
+ + {sessions.length === 0 ? ( +
+ {t("dashboard:sessions.noSessions")} +
+ ) : ( +
+ {sessions.map((session) => { + const isExpanded = expandedTaskId === session.taskId + return ( + + ) + })} +
+ )} +
+ ) + }, +) + +SessionList.displayName = "SessionList" + +export default SessionList diff --git a/webview-ui/src/components/dashboard/__tests__/DashboardSummary.spec.tsx b/webview-ui/src/components/dashboard/__tests__/DashboardSummary.spec.tsx new file mode 100644 index 0000000000..820c4fe2d0 --- /dev/null +++ b/webview-ui/src/components/dashboard/__tests__/DashboardSummary.spec.tsx @@ -0,0 +1,110 @@ +// npx vitest run src/components/dashboard/__tests__/DashboardSummary.spec.tsx + +import React from "react" +import { render } from "@/utils/test-utils" + +import type { StatsBucket } from "@roo-code/types" + +import DashboardSummary from "../DashboardSummary" + +// Mock i18n +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + initReactI18next: { + type: "3rdParty", + init: () => {}, + }, + Trans: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +// ── Test fixtures ──────────────────────────────────────────────────────────── + +function makeBucket(overrides: Partial = {}): StatsBucket { + return { + key: {}, + events: 10, + completedCalls: 8, + failedCalls: 1, + cancelledCalls: 1, + inputTokens: 5000, + outputTokens: 2500, + cacheReadTokens: 1000, + cacheWriteTokens: 500, + reasoningTokens: 200, + totalTokens: 7500, + costUsd: 0.15, + unknownEventCount: 0, + ...overrides, + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("DashboardSummary", () => { + it("renders the summary container", () => { + const { container } = render() + const summary = container.querySelector('[data-testid="dashboard-summary"]') + expect(summary).toBeTruthy() + }) + + it("renders all five summary cards", () => { + const { container } = render() + const summary = container.querySelector('[data-testid="dashboard-summary"]') + // Total tokens, input, output, cache, cost + expect(summary?.textContent).toContain("dashboard:summary.totalTokens") + expect(summary?.textContent).toContain("dashboard:summary.inputTokens") + expect(summary?.textContent).toContain("dashboard:summary.outputTokens") + expect(summary?.textContent).toContain("dashboard:summary.cacheTokens") + expect(summary?.textContent).toContain("dashboard:summary.cost") + }) + + it("displays formatted total tokens", () => { + const { container } = render() + const summary = container.querySelector('[data-testid="dashboard-summary"]') + expect(summary?.textContent).toContain("1.50M") + }) + + it("displays formatted cost", () => { + const { container } = render() + const summary = container.querySelector('[data-testid="dashboard-summary"]') + expect(summary?.textContent).toContain("$1.23") + }) + + it("displays zero values correctly", () => { + const { container } = render() + const summary = container.querySelector('[data-testid="dashboard-summary"]') + expect(summary?.textContent).toContain("0") + expect(summary?.textContent).toContain("$0.00") + }) + + it("shows unknown event count when > 0", () => { + const { container } = render() + const summary = container.querySelector('[data-testid="dashboard-summary"]') + expect(summary?.textContent).toContain("3 uncertain") + }) + + it("does not show unknown event count when 0", () => { + const { container } = render() + const summary = container.querySelector('[data-testid="dashboard-summary"]') + expect(summary?.textContent).not.toContain("uncertain") + }) + + it("computes cache total from read + write", () => { + const { container } = render() + const summary = container.querySelector('[data-testid="dashboard-summary"]') + // 2000 + 3000 = 5000 -> "5.0K" + expect(summary?.textContent).toContain("5.0K") + }) +}) diff --git a/webview-ui/src/components/dashboard/__tests__/DashboardView.spec.tsx b/webview-ui/src/components/dashboard/__tests__/DashboardView.spec.tsx new file mode 100644 index 0000000000..d6f9a0bd39 --- /dev/null +++ b/webview-ui/src/components/dashboard/__tests__/DashboardView.spec.tsx @@ -0,0 +1,1251 @@ +// npx vitest run src/components/dashboard/__tests__/DashboardView.spec.tsx + +import React from "react" +import { render, fireEvent, waitFor, act } from "@/utils/test-utils" + +import type { StatsBucket, StatsSnapshot, SessionSummary } from "@roo-code/types" + +import DashboardView from "../DashboardView" + +// ── Mock i18n ─────────────────────────────────────────────────────────────── +// DashboardView uses useAppTranslation from @/i18n/TranslationContext (not +// react-i18next directly), so we must mock that module. The real +// TranslationContext calls useExtensionState() internally, which requires a +// provider we don't have in tests. + +// Stable t function reference so useEffect dependencies don't change on every render +const stableT = (key: string) => key + +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: stableT, + i18n: {}, + }), + TranslationProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +// ── vscode mock ────────────────────────────────────────────────────────────── + +const postMessageMock = vi.fn() +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: (msg: unknown) => postMessageMock(msg), + }, +})) + +// ── Mock child components to avoid deep rendering ──────────────────────────── + +vi.mock("../DashboardSummary", () => ({ + default: () =>
, +})) + +vi.mock("../SessionList", () => ({ + default: () =>
, +})) + +vi.mock("../../stats/UsageHeatmap", () => ({ + default: () =>
, +})) + +// ── Mock common/Tab to avoid useExtensionState dependency ─────────────────── +// TabContent calls useExtensionState() which requires a provider. + +vi.mock("@/components/common/Tab", () => ({ + Tab: ({ children, ...props }: React.HTMLAttributes) =>
{children}
, + TabHeader: ({ children, ...props }: React.HTMLAttributes) =>
{children}
, + TabContent: ({ children, ...props }: React.HTMLAttributes) =>
{children}
, +})) + +// ── Mock AlertDialog to avoid Radix portal issues in tests ────────────────── +// Radix AlertDialog renders content in a portal to document.body, which makes +// it hard to query with container.querySelector. We mock it to render inline +// when open=true. The mock uses React context to wire up onOpenChange so +// AlertDialogCancel can close the dialog (matching Radix behavior). + +const AlertDialogContext = React.createContext<{ onOpenChange?: (open: boolean) => void }>({}) + +vi.mock("@/components/ui/alert-dialog", () => ({ + AlertDialog: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void + }) => ( + +
+ {open ? children : null} +
+
+ ), + AlertDialogContent: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + AlertDialogHeader: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + AlertDialogTitle: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + AlertDialogDescription: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + AlertDialogFooter: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + AlertDialogCancel: ({ children, ...props }: React.HTMLAttributes) => { + const { onOpenChange } = React.useContext(AlertDialogContext) + return ( + + ) + }, + AlertDialogAction: ({ children, ...props }: React.HTMLAttributes) => ( + + ), +})) + +// ── Test fixtures ──────────────────────────────────────────────────────────── + +function makeBucket(overrides: Partial = {}): StatsBucket { + return { + key: {}, + events: 10, + completedCalls: 8, + failedCalls: 1, + cancelledCalls: 1, + inputTokens: 5000, + outputTokens: 2500, + cacheReadTokens: 1000, + cacheWriteTokens: 500, + reasoningTokens: 200, + totalTokens: 7500, + costUsd: 0.15, + unknownEventCount: 0, + ...overrides, + } +} + +function makeSnapshot(overrides: Partial = {}): StatsSnapshot { + const totals = makeBucket({ events: 10, totalTokens: 7500 }) + return { + query: { timezone: "UTC", groupBy: ["day"], includeCancelled: false }, + generatedAt: new Date().toISOString(), + buckets: [makeBucket({ key: { model: "gpt-4" } })], + totals, + coverage: { + recordingPaused: false, + backfilledEventCount: 0, + }, + ...overrides, + } +} + +function makeSession(overrides: Partial = {}): SessionSummary { + return { + taskId: "task-001", + title: "Test session", + timestamp: Date.now(), + model: "gpt-4", + provider: "openai", + mode: "code", + models: ["gpt-4"], + modes: ["code"], + totalTokens: 1500, + totalCost: 0.05, + callCount: 1, + ...overrides, + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Extracts the latest requestId from postMessage calls matching the request + * message type (e.g. "getUsageStats", "getDashboardSessions"). This is more + * reliable than matching by requestId prefix because multiple request types + * share the "dashboard-" prefix (e.g. "dashboard-{ts}" for stats and + * "dashboard-sessions-{ts}" for sessions). + */ +function getLatestRequestIdByType(requestType: string): string { + const calls = postMessageMock.mock.calls + const matching = calls.filter((call) => { + const msg = call[0] as { type: string; requestId?: string } + return msg.type === requestType && msg.requestId + }) + expect(matching.length).toBeGreaterThan(0) + const lastCall = matching[matching.length - 1][0] as { requestId: string } + return lastCall.requestId +} + +/** + * Simulates the extension host responding to a getUsageStats request. + */ +function simulateStatsResponse(snapshot: Partial | null, requestId?: string) { + const rid = requestId ?? getLatestRequestIdByType("getUsageStats") + const data: Record = { + type: "getUsageStatsResponse", + requestId: rid, + } + if (snapshot !== null) { + data.usageStatsSnapshot = makeSnapshot(snapshot) + } + window.dispatchEvent(new MessageEvent("message", { data })) +} + +/** + * Simulates the extension host responding to a getDashboardSessions request. + */ +function simulateSessionsResponse(sessions: SessionSummary[] | null, error?: string, requestId?: string) { + const rid = requestId ?? getLatestRequestIdByType("getDashboardSessions") + const data: Record = { + type: "dashboardSessionsResponse", + requestId: rid, + } + if (sessions !== null) { + data.dashboardSessions = sessions + } else { + data.dashboardSessions = null + if (error) data.error = error + } + window.dispatchEvent(new MessageEvent("message", { data })) +} + +/** + * Simulates a requestClearNonceResponse from the host. + */ +function simulateClearNonceResponse(nonce: string | null, error?: string) { + const rid = getLatestRequestIdByType("requestClearNonce") + const data: Record = { + type: "requestClearNonceResponse", + requestId: rid, + } + if (nonce) { + data.clearNonce = nonce + } else { + data.clearNonce = null + if (error) data.error = error + } + window.dispatchEvent(new MessageEvent("message", { data })) +} + +/** + * Simulates a clearUsageStatsResponse from the host. + */ +function simulateClearResponse(success: boolean, error?: string, nonce?: string) { + const data: Record = { + type: "clearUsageStatsResponse", + requestId: nonce ?? "test-clear-nonce", + clearUsageStatsResult: { success, ...(error ? { error } : {}) }, + } + window.dispatchEvent(new MessageEvent("message", { data })) +} + +/** + * Simulates an exportUsageStatsResponse from the host. + */ +function simulateExportResponse(error?: string) { + const rid = getLatestRequestIdByType("exportUsageStats") + const data: Record = { + type: "exportUsageStatsResponse", + requestId: rid, + exportUsageStatsResult: { + format: "json", + data: "[]", + ...(error ? { error } : {}), + }, + } + window.dispatchEvent(new MessageEvent("message", { data })) +} + +/** + * Simulates a usageStatsChanged event. + */ +function simulateUsageStatsChanged() { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "usageStatsChanged" }, + }), + ) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("DashboardView", () => { + beforeEach(() => { + postMessageMock.mockClear() + }) + + // ── 1. Initial mount & buildQuery ────────────────────────────────────── + + describe("initial mount", () => { + it("sends getUsageStats and getDashboardSessions on mount", () => { + render( {}} />) + + expect(postMessageMock).toHaveBeenCalledTimes(2) + + const statsCall = postMessageMock.mock.calls.find( + (c) => (c[0] as { type: string }).type === "getUsageStats", + ) + expect(statsCall).toBeTruthy() + const statsMsg = statsCall![0] as { + requestId: string + usageStatsQuery: { preset: string; groupBy: string[] } + } + expect(statsMsg.requestId).toMatch(/^dashboard-/) + expect(statsMsg.usageStatsQuery.preset).toBe("today") + expect(statsMsg.usageStatsQuery.groupBy).toContain("model") + expect(statsMsg.usageStatsQuery.groupBy).not.toContain("day") + + const sessionsCall = postMessageMock.mock.calls.find( + (c) => (c[0] as { type: string }).type === "getDashboardSessions", + ) + expect(sessionsCall).toBeTruthy() + }) + + it("renders loading state initially", () => { + const { container } = render( {}} />) + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeTruthy() + }) + }) + + // ── 2. handlePresetChange ────────────────────────────────────────────── + + describe("handlePresetChange", () => { + it("changes preset to 7d and triggers fetchStats + fetchSessions", async () => { + const { container } = render( {}} />) + + // Respond to initial mount requests + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + postMessageMock.mockClear() + + // Click 7d preset + const btn7d = container.querySelector('[data-testid="dashboard-range-7d"]') as HTMLButtonElement + fireEvent.click(btn7d) + + await waitFor(() => { + expect(postMessageMock).toHaveBeenCalledTimes(2) + }) + + const statsCall = postMessageMock.mock.calls.find( + (c) => (c[0] as { type: string }).type === "getUsageStats", + )![0] as { usageStatsQuery: { preset: string } } + expect(statsCall.usageStatsQuery.preset).toBe("7d") + }) + + it("changes preset to 30d and triggers fetch", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + postMessageMock.mockClear() + + const btn30d = container.querySelector('[data-testid="dashboard-range-30d"]') as HTMLButtonElement + fireEvent.click(btn30d) + + await waitFor(() => { + expect(postMessageMock).toHaveBeenCalledTimes(2) + }) + + const statsCall = postMessageMock.mock.calls.find( + (c) => (c[0] as { type: string }).type === "getUsageStats", + )![0] as { usageStatsQuery: { preset: string } } + expect(statsCall.usageStatsQuery.preset).toBe("30d") + }) + + it("changes preset to all and triggers fetch", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + postMessageMock.mockClear() + + const btnAll = container.querySelector('[data-testid="dashboard-range-all"]') as HTMLButtonElement + fireEvent.click(btnAll) + + await waitFor(() => { + expect(postMessageMock).toHaveBeenCalledTimes(2) + }) + + const statsCall = postMessageMock.mock.calls.find( + (c) => (c[0] as { type: string }).type === "getUsageStats", + )![0] as { usageStatsQuery: { preset: string } } + expect(statsCall.usageStatsQuery.preset).toBe("all") + }) + + it("selects custom preset and shows custom date range inputs", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + postMessageMock.mockClear() + + const btnCustom = container.querySelector('[data-testid="dashboard-range-custom"]') as HTMLButtonElement + fireEvent.click(btnCustom) + + // Custom range inputs should appear + expect(container.querySelector('[data-testid="dashboard-custom-range"]')).toBeTruthy() + expect(container.querySelector('[data-testid="dashboard-custom-from"]')).toBeTruthy() + expect(container.querySelector('[data-testid="dashboard-custom-to"]')).toBeTruthy() + + // Selecting custom with valid dates should trigger fetch + await waitFor(() => { + expect(postMessageMock).toHaveBeenCalledTimes(2) + }) + + const statsCall = postMessageMock.mock.calls.find( + (c) => (c[0] as { type: string }).type === "getUsageStats", + )![0] as { usageStatsQuery: { from?: string; to?: string; preset?: string } } + expect(statsCall.usageStatsQuery.from).toBeTruthy() + expect(statsCall.usageStatsQuery.to).toBeTruthy() + expect(statsCall.usageStatsQuery.preset).toBeUndefined() + }) + }) + + // ── 3. handleGroupByChange ───────────────────────────────────────────── + + describe("handleGroupByChange", () => { + it("changes groupBy to provider and triggers fetch", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + postMessageMock.mockClear() + + const btnProvider = container.querySelector( + '[data-testid="dashboard-groupby-provider"]', + ) as HTMLButtonElement + fireEvent.click(btnProvider) + + await waitFor(() => { + expect(postMessageMock).toHaveBeenCalledTimes(2) + }) + + const statsCall = postMessageMock.mock.calls.find( + (c) => (c[0] as { type: string }).type === "getUsageStats", + )![0] as { usageStatsQuery: { groupBy: string[] } } + expect(statsCall.usageStatsQuery.groupBy).toContain("provider") + }) + + it("changes groupBy to mode and triggers fetch", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + postMessageMock.mockClear() + + const btnMode = container.querySelector('[data-testid="dashboard-groupby-mode"]') as HTMLButtonElement + fireEvent.click(btnMode) + + await waitFor(() => { + expect(postMessageMock).toHaveBeenCalledTimes(2) + }) + + const statsCall = postMessageMock.mock.calls.find( + (c) => (c[0] as { type: string }).type === "getUsageStats", + )![0] as { usageStatsQuery: { groupBy: string[] } } + expect(statsCall.usageStatsQuery.groupBy).toContain("mode") + }) + }) + + // ── 4. handleRefresh ─────────────────────────────────────────────────── + + describe("handleRefresh", () => { + it("re-fetches stats and sessions on refresh click", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + postMessageMock.mockClear() + + const refreshBtn = container.querySelector('[data-testid="dashboard-refresh-button"]') as HTMLButtonElement + fireEvent.click(refreshBtn) + + await waitFor(() => { + expect(postMessageMock).toHaveBeenCalledTimes(2) + }) + + const statsCall = postMessageMock.mock.calls.find( + (c) => (c[0] as { type: string }).type === "getUsageStats", + ) + expect(statsCall).toBeTruthy() + }) + }) + + // ── 5. Message handlers ──────────────────────────────────────────────── + + describe("message handlers", () => { + it("handles getUsageStatsResponse with data", async () => { + const { container } = render( {}} />) + + const snapshot = makeSnapshot({ + buckets: [makeBucket({ key: { model: "claude-3" }, totalTokens: 10000 })], + totals: makeBucket({ events: 5, totalTokens: 10000 }), + }) + + simulateStatsResponse(snapshot) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + expect(container.querySelector('[data-testid="dashboard-breakdown"]')).toBeTruthy() + }) + }) + + it("handles getUsageStatsResponse without snapshot (error)", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(null) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-error"]')).toBeTruthy() + }) + }) + + it("handles dashboardSessionsResponse with sessions", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([makeSession({ taskId: "task-123", title: "My Session" })]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + }) + + it("handles dashboardSessionsResponse with error", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse(null, "Session fetch failed") + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + }) + + it("handles usageStatsChanged with debounced refetch", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + postMessageMock.mockClear() + + // Use fake timers only for the debounce portion + vi.useFakeTimers() + + // Trigger usageStatsChanged event + simulateUsageStatsChanged() + + // Before debounce timer fires, no new requests + expect(postMessageMock).toHaveBeenCalledTimes(0) + + // Advance past the 250ms debounce + act(() => { + vi.advanceTimersByTime(300) + }) + + // After debounce, refetch should have fired + expect(postMessageMock).toHaveBeenCalledTimes(2) + + vi.useRealTimers() + }) + + it("handles requestClearNonceResponse with nonce (opens dialog)", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + // Click clear button + const clearBtn = container.querySelector('[data-testid="dashboard-clear-button"]') as HTMLButtonElement + fireEvent.click(clearBtn) + + await waitFor(() => { + expect( + postMessageMock.mock.calls.some((c) => (c[0] as { type: string }).type === "requestClearNonce"), + ).toBe(true) + }) + + // Simulate nonce response + simulateClearNonceResponse("nonce-123") + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-clear-dialog"]')).toBeTruthy() + }) + }) + + it("handles requestClearNonceResponse without nonce (error)", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + const clearBtn = container.querySelector('[data-testid="dashboard-clear-button"]') as HTMLButtonElement + fireEvent.click(clearBtn) + + await waitFor(() => { + expect( + postMessageMock.mock.calls.some((c) => (c[0] as { type: string }).type === "requestClearNonce"), + ).toBe(true) + }) + + simulateClearNonceResponse(null, "Nonce error") + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-error"]')).toBeTruthy() + }) + }) + + it("handles clearUsageStatsResponse success (refetches data)", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + // Open clear dialog + const clearBtn = container.querySelector('[data-testid="dashboard-clear-button"]') as HTMLButtonElement + fireEvent.click(clearBtn) + simulateClearNonceResponse("nonce-abc") + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-clear-dialog"]')).toBeTruthy() + }) + + // Confirm clear + const confirmBtn = container.querySelector('[data-testid="dashboard-clear-confirm"]') as HTMLButtonElement + fireEvent.click(confirmBtn) + + await waitFor(() => { + expect( + postMessageMock.mock.calls.some((c) => (c[0] as { type: string }).type === "clearUsageStats"), + ).toBe(true) + }) + + postMessageMock.mockClear() + + // Simulate clear success response + simulateClearResponse(true, undefined, "nonce-abc") + + await waitFor(() => { + // Dialog should close + expect(container.querySelector('[data-testid="dashboard-clear-dialog"]')).toBeFalsy() + // Should refetch stats and sessions + expect(postMessageMock).toHaveBeenCalledTimes(2) + }) + }) + + it("handles clearUsageStatsResponse failure (shows error)", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + const clearBtn = container.querySelector('[data-testid="dashboard-clear-button"]') as HTMLButtonElement + fireEvent.click(clearBtn) + simulateClearNonceResponse("nonce-xyz") + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-clear-dialog"]')).toBeTruthy() + }) + + const confirmBtn = container.querySelector('[data-testid="dashboard-clear-confirm"]') as HTMLButtonElement + fireEvent.click(confirmBtn) + + simulateClearResponse(false, "Clear failed", "nonce-xyz") + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-error"]')).toBeTruthy() + }) + }) + + it("handles exportUsageStatsResponse with error", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + // Click export CSV + const exportBtn = container.querySelector('[data-testid="dashboard-export-csv"]') as HTMLButtonElement + fireEvent.click(exportBtn) + + await waitFor(() => { + expect( + postMessageMock.mock.calls.some((c) => (c[0] as { type: string }).type === "exportUsageStats"), + ).toBe(true) + }) + + simulateExportResponse("Export failed") + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-error"]')).toBeTruthy() + }) + }) + + it("handles exportUsageStatsResponse without error (no error shown)", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + const exportBtn = container.querySelector('[data-testid="dashboard-export-csv"]') as HTMLButtonElement + fireEvent.click(exportBtn) + + simulateExportResponse() + + // No error should be shown + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-error"]')).toBeFalsy() + }) + }) + + it("ignores stale getUsageStatsResponse (wrong requestId)", async () => { + const { container } = render( {}} />) + + // Send a response with a non-matching requestId + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "getUsageStatsResponse", + requestId: "stale-id", + usageStatsSnapshot: makeSnapshot(), + }, + }), + ) + + // Should still be loading because the stale response was ignored + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeTruthy() + }) + + it("ignores stale dashboardSessionsResponse (wrong requestId)", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + + // Send a sessions response with non-matching requestId + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "dashboardSessionsResponse", + requestId: "stale-sessions-id", + dashboardSessions: [makeSession()], + }, + }), + ) + + // The sessions loading state should still be active (or at least + // the stale response should not have been applied) + // We verify by checking that no error was set from the stale response + expect(container.querySelector('[data-testid="dashboard-error"]')).toBeFalsy() + }) + }) + + // ── 6. handleExport ──────────────────────────────────────────────────── + + describe("handleExport", () => { + it("sends exportUsageStats message with csv format", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + postMessageMock.mockClear() + + const exportBtn = container.querySelector('[data-testid="dashboard-export-csv"]') as HTMLButtonElement + fireEvent.click(exportBtn) + + expect(postMessageMock).toHaveBeenCalledTimes(1) + const msg = postMessageMock.mock.calls[0][0] as { type: string; exportUsageStatsFormat: string } + expect(msg.type).toBe("exportUsageStats") + expect(msg.exportUsageStatsFormat).toBe("csv") + }) + + it("disables export buttons when no data", () => { + const { container } = render( {}} />) + + // Simulate empty stats response (no data) + simulateStatsResponse( + makeSnapshot({ + totals: makeBucket({ events: 0, totalTokens: 0 }), + buckets: [], + }), + ) + simulateSessionsResponse([]) + + // Wait for loading to clear + return waitFor(() => { + const exportCsv = container.querySelector('[data-testid="dashboard-export-csv"]') as HTMLButtonElement + expect(exportCsv.disabled).toBe(true) + }) + }) + }) + + // ── 7. handleClearRequest / handleClearConfirm ──────────────────────── + + describe("clear flow", () => { + it("sends requestClearNonce on clear button click", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + postMessageMock.mockClear() + + const clearBtn = container.querySelector('[data-testid="dashboard-clear-button"]') as HTMLButtonElement + fireEvent.click(clearBtn) + + expect(postMessageMock).toHaveBeenCalledTimes(1) + const msg = postMessageMock.mock.calls[0][0] as { type: string } + expect(msg.type).toBe("requestClearNonce") + }) + + it("sends clearUsageStats with nonce on confirm", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + // Request nonce + const clearBtn = container.querySelector('[data-testid="dashboard-clear-button"]') as HTMLButtonElement + fireEvent.click(clearBtn) + simulateClearNonceResponse("my-nonce-123") + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-clear-dialog"]')).toBeTruthy() + }) + + postMessageMock.mockClear() + + // Confirm + const confirmBtn = container.querySelector('[data-testid="dashboard-clear-confirm"]') as HTMLButtonElement + fireEvent.click(confirmBtn) + + expect(postMessageMock).toHaveBeenCalledTimes(1) + const msg = postMessageMock.mock.calls[0][0] as { + type: string + requestId: string + clearUsageStatsNonce: string + } + expect(msg.type).toBe("clearUsageStats") + expect(msg.requestId).toBe("my-nonce-123") + expect(msg.clearUsageStatsNonce).toBe("my-nonce-123") + }) + + it("closes dialog on cancel", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + const clearBtn = container.querySelector('[data-testid="dashboard-clear-button"]') as HTMLButtonElement + fireEvent.click(clearBtn) + simulateClearNonceResponse("nonce-cancel") + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-clear-dialog"]')).toBeTruthy() + }) + + const cancelBtn = container.querySelector('[data-testid="dashboard-clear-cancel"]') as HTMLButtonElement + fireEvent.click(cancelBtn) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-clear-dialog"]')).toBeFalsy() + }) + }) + }) + + // ── 8. Custom date range ────────────────────────────────────────────── + + describe("custom date range", () => { + it("updates customFrom input value", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + // Select custom preset + const btnCustom = container.querySelector('[data-testid="dashboard-range-custom"]') as HTMLButtonElement + fireEvent.click(btnCustom) + + const fromInput = container.querySelector('[data-testid="dashboard-custom-from"]') as HTMLInputElement + fireEvent.change(fromInput, { target: { value: "2026-01-15" } }) + + expect(fromInput.value).toBe("2026-01-15") + }) + + it("updates customTo input value", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + const btnCustom = container.querySelector('[data-testid="dashboard-range-custom"]') as HTMLButtonElement + fireEvent.click(btnCustom) + + const toInput = container.querySelector('[data-testid="dashboard-custom-to"]') as HTMLInputElement + fireEvent.change(toInput, { target: { value: "2026-06-20" } }) + + expect(toInput.value).toBe("2026-06-20") + }) + + it("applies custom range on apply button click", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + // Select custom + const btnCustom = container.querySelector('[data-testid="dashboard-range-custom"]') as HTMLButtonElement + fireEvent.click(btnCustom) + + // Change dates + const fromInput = container.querySelector('[data-testid="dashboard-custom-from"]') as HTMLInputElement + fireEvent.change(fromInput, { target: { value: "2026-01-01" } }) + const toInput = container.querySelector('[data-testid="dashboard-custom-to"]') as HTMLInputElement + fireEvent.change(toInput, { target: { value: "2026-01-31" } }) + + postMessageMock.mockClear() + + // Click apply + const applyBtn = container.querySelector('[data-testid="dashboard-custom-apply"]') as HTMLButtonElement + fireEvent.click(applyBtn) + + await waitFor(() => { + expect(postMessageMock).toHaveBeenCalledTimes(2) + }) + + const statsCall = postMessageMock.mock.calls.find( + (c) => (c[0] as { type: string }).type === "getUsageStats", + )![0] as { usageStatsQuery: { from?: string; to?: string } } + // The component converts YYYY-MM-DD to ISO via new Date(`${date}T00:00:00`) + // which may shift the date depending on timezone. We verify the from/to + // are present and correspond to the correct day when parsed back. + expect(statsCall.usageStatsQuery.from).toBeTruthy() + expect(statsCall.usageStatsQuery.to).toBeTruthy() + // Parse the ISO string and check the date part matches the input + const fromDate = new Date(statsCall.usageStatsQuery.from!) + const toDate = new Date(statsCall.usageStatsQuery.to!) + // The from date should be Jan 1 (may be Dec 31 in UTC, but the + // local date should be Jan 1). We check the ISO date string contains + // "01-01" or "12-31" (timezone boundary). + const fromStr = statsCall.usageStatsQuery.from! + const toStr = statsCall.usageStatsQuery.to! + expect(fromStr).toMatch(/2026-01-01|2025-12-31/) + expect(toStr).toMatch(/2026-01-31|2026-01-30/) + expect(fromDate).toBeInstanceOf(Date) + expect(toDate).toBeInstanceOf(Date) + }) + }) + + // ── 9. Session handling ──────────────────────────────────────────────── + + describe("session handling", () => { + it("renders session list when data is loaded", async () => { + const { container } = render( {}} />) + + // Wait for useEffect to run (postMessage called on mount) + await waitFor(() => { + expect(postMessageMock).toHaveBeenCalled() + }) + + // Use act to ensure React processes the message events + await act(async () => { + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([makeSession({ taskId: "task-1", title: "Session One" })]) + }) + + // Verify stats loaded (loading cleared, data section visible) + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + // Verify sessions loaded (sessions loading cleared) + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-sessions-loading"]')).toBeFalsy() + expect(container.querySelector('[data-testid="dashboard-sessions-error"]')).toBeFalsy() + }) + }) + + it("shows sessions loading state before response", async () => { + const { container } = render( {}} />) + + // Respond to stats but not sessions yet + simulateStatsResponse(makeSnapshot()) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-loading"]')).toBeFalsy() + }) + + // Sessions loading indicator should be visible + expect(container.querySelector('[data-testid="dashboard-sessions-loading"]')).toBeTruthy() + }) + + it("shows sessions error state when sessions fetch fails", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse(null, "Network error") + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-sessions-error"]')).toBeTruthy() + }) + }) + }) + + // ── 10. UI rendering states ──────────────────────────────────────────── + + describe("UI rendering", () => { + it("renders the dashboard view container", () => { + const { container } = render( {}} />) + expect(container.querySelector('[data-testid="dashboard-view"]')).toBeTruthy() + }) + + it("renders the done button", () => { + const { container } = render( {}} />) + expect(container.querySelector('[data-testid="dashboard-done-button"]')).toBeTruthy() + }) + + it("calls onDone when done button is clicked", () => { + const onDone = vi.fn() + const { container } = render() + + const doneBtn = container.querySelector('[data-testid="dashboard-done-button"]') as HTMLButtonElement + fireEvent.click(doneBtn) + + expect(onDone).toHaveBeenCalledTimes(1) + }) + + it("renders all range preset buttons", () => { + const { container } = render( {}} />) + + expect(container.querySelector('[data-testid="dashboard-range-today"]')).toBeTruthy() + expect(container.querySelector('[data-testid="dashboard-range-7d"]')).toBeTruthy() + expect(container.querySelector('[data-testid="dashboard-range-30d"]')).toBeTruthy() + expect(container.querySelector('[data-testid="dashboard-range-custom"]')).toBeTruthy() + expect(container.querySelector('[data-testid="dashboard-range-all"]')).toBeTruthy() + }) + + it("renders all groupBy buttons", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-breakdown"]')).toBeTruthy() + }) + + expect(container.querySelector('[data-testid="dashboard-groupby-model"]')).toBeTruthy() + expect(container.querySelector('[data-testid="dashboard-groupby-provider"]')).toBeTruthy() + expect(container.querySelector('[data-testid="dashboard-groupby-mode"]')).toBeTruthy() + }) + + it("renders empty state when no data", async () => { + const { container } = render( {}} />) + + simulateStatsResponse( + makeSnapshot({ + totals: makeBucket({ events: 0, totalTokens: 0 }), + buckets: [], + }), + ) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-empty"]')).toBeTruthy() + }) + }) + + it("renders error state with refresh button", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(null) + simulateSessionsResponse([]) + + await waitFor(() => { + const errorEl = container.querySelector('[data-testid="dashboard-error"]') + expect(errorEl).toBeTruthy() + // Error state should have a refresh button + const refreshBtn = errorEl?.querySelector("button") + expect(refreshBtn).toBeTruthy() + }) + }) + + it("renders data state with breakdown table when data exists", async () => { + const { container } = render( {}} />) + + simulateStatsResponse( + makeSnapshot({ + buckets: [ + makeBucket({ key: { model: "gpt-4" }, totalTokens: 5000, events: 5 }), + makeBucket({ key: { model: "claude-3" }, totalTokens: 3000, events: 3 }), + ], + totals: makeBucket({ events: 8, totalTokens: 8000 }), + }), + ) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-breakdown"]')).toBeTruthy() + }) + + // Verify table rows + const rows = container.querySelectorAll("tbody tr") + expect(rows.length).toBe(2) + }) + + it("renders coverage section when snapshot has coverage", async () => { + const { container } = render( {}} />) + + simulateStatsResponse( + makeSnapshot({ + coverage: { + firstEventAt: "2026-01-01T00:00:00Z", + lastEventAt: "2026-07-01T00:00:00Z", + recordingPaused: false, + backfilledEventCount: 5, + }, + }), + ) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-coverage"]')).toBeTruthy() + }) + }) + + it("renders coverage with recordingPaused indicator", async () => { + const { container } = render( {}} />) + + simulateStatsResponse( + makeSnapshot({ + coverage: { + recordingPaused: true, + backfilledEventCount: 0, + }, + }), + ) + simulateSessionsResponse([]) + + await waitFor(() => { + const coverage = container.querySelector('[data-testid="dashboard-coverage"]') + expect(coverage).toBeTruthy() + expect(coverage?.textContent).toContain("dashboard:coverage.paused") + }) + }) + + it("renders DashboardSummary and UsageHeatmap when data exists", async () => { + const { container } = render( {}} />) + + simulateStatsResponse(makeSnapshot()) + simulateSessionsResponse([]) + + await waitFor(() => { + expect(container.querySelector('[data-testid="dashboard-summary"]')).toBeTruthy() + expect(container.querySelector('[data-testid="usage-heatmap"]')).toBeTruthy() + }) + }) + }) +}) diff --git a/webview-ui/src/components/dashboard/__tests__/SessionDetail.spec.tsx b/webview-ui/src/components/dashboard/__tests__/SessionDetail.spec.tsx new file mode 100644 index 0000000000..569a394765 --- /dev/null +++ b/webview-ui/src/components/dashboard/__tests__/SessionDetail.spec.tsx @@ -0,0 +1,175 @@ +// npx vitest run src/components/dashboard/__tests__/SessionDetail.spec.tsx + +import React from "react" +import { render } from "@/utils/test-utils" + +import type { SessionDetail as SessionDetailType, APICallRecord } from "@roo-code/types" + +import SessionDetail from "../SessionDetail" + +// Mock i18n +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + initReactI18next: { + type: "3rdParty", + init: () => {}, + }, + Trans: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +// ── Test fixtures ──────────────────────────────────────────────────────────── + +function makeApiCall(overrides: Partial = {}): APICallRecord { + return { + index: 1, + mode: "code", + timestamp: Date.now(), + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + costUsd: 0.05, + status: "completed", + model: "gpt-4", + ...overrides, + } +} + +function makeDetail(overrides: Partial = {}): SessionDetailType { + return { + taskId: "task-001", + title: "Test session", + timestamp: Date.now(), + model: "gpt-4", + provider: "openai", + mode: "code", + models: ["gpt-4"], + modes: ["code"], + totalTokens: 150, + totalCost: 0.05, + callCount: 1, + apiCalls: [makeApiCall()], + ...overrides, + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("SessionDetail", () => { + it("renders the session detail container", () => { + const { container } = render() + const detail = container.querySelector('[data-testid="dashboard-session-detail"]') + expect(detail).toBeTruthy() + }) + + it("renders the summary header label", () => { + const { container } = render() + expect(container.textContent).toContain("dashboard:sessionDetail.summary") + }) + + it("renders formatted cost in summary header", () => { + const { container } = render( + , + ) + expect(container.textContent).toContain("$0.15") + }) + + it("renders input/output token totals from apiCalls", () => { + const { container } = render( + , + ) + // 1000 + 500 = 1500 -> "1.5K" + expect(container.textContent).toContain("1.5K") + }) + + it("renders the API call table when apiCalls exist", () => { + const { container } = render() + const callsTable = container.querySelector('[data-testid="dashboard-session-detail-calls"]') + expect(callsTable).toBeTruthy() + expect(container.textContent).toContain("code") + expect(container.textContent).toContain("architect") + }) + + it("renders no-calls message when apiCalls is empty", () => { + const { container } = render() + const noCalls = container.querySelector('[data-testid="dashboard-session-detail-no-calls"]') + expect(noCalls).toBeTruthy() + expect(noCalls?.textContent).toContain("dashboard:sessionDetail.noApiCalls") + }) + + it("renders status icons for completed, failed, and cancelled calls", () => { + const { container } = render() + // Check that status icons are rendered (role="img") + const statusIcons = container.querySelectorAll('[role="img"]') + expect(statusIcons.length).toBe(3) + expect(statusIcons[0].textContent).toContain("✅") + expect(statusIcons[1].textContent).toContain("❌") + expect(statusIcons[2].textContent).toContain("🔄") + }) + + it("renders formatted cost per API call", () => { + const { container } = render( + , + ) + expect(container.textContent).toContain("$1.23") + }) + + it("renders model name per API call", () => { + const { container } = render( + , + ) + expect(container.textContent).toContain("claude-3-opus") + }) + + it("renders dash for empty mode", () => { + const { container } = render( + , + ) + expect(container.textContent).toContain("—") + }) + + it("renders multiple models in summary header", () => { + const { container } = render() + expect(container.textContent).toContain("gpt-4") + expect(container.textContent).toContain("claude-3") + }) +}) diff --git a/webview-ui/src/components/dashboard/__tests__/SessionList.spec.tsx b/webview-ui/src/components/dashboard/__tests__/SessionList.spec.tsx new file mode 100644 index 0000000000..ebf99188b0 --- /dev/null +++ b/webview-ui/src/components/dashboard/__tests__/SessionList.spec.tsx @@ -0,0 +1,188 @@ +// npx vitest run src/components/dashboard/__tests__/SessionList.spec.tsx + +import React from "react" +import { render, fireEvent } from "@/utils/test-utils" + +import type { SessionSummary, SessionDetail as SessionDetailType } from "@roo-code/types" + +import SessionList from "../SessionList" + +// Mock i18n +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + initReactI18next: { + type: "3rdParty", + init: () => {}, + }, + Trans: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +// ── Test fixtures ──────────────────────────────────────────────────────────── + +function makeSession(overrides: Partial = {}): SessionSummary { + return { + taskId: "task-001", + title: "Test session", + timestamp: Date.now(), + model: "gpt-4", + provider: "openai", + mode: "code", + models: ["gpt-4"], + modes: ["code"], + totalTokens: 1500, + totalCost: 0.05, + callCount: 1, + ...overrides, + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("SessionList", () => { +const defaultProps = { + expandedTaskId: undefined, + sessionDetails: {} as Record, + sessionDetailErrors: {} as Record, + sessionDetailLoading: new Set(), + onToggleSession: vi.fn(), +} + + it("renders the sessions container", () => { + const { container } = render( + , + ) + const sessions = container.querySelector('[data-testid="dashboard-sessions"]') + expect(sessions).toBeTruthy() + }) + + it("renders empty state when no sessions", () => { + const { container } = render( + , + ) + const empty = container.querySelector('[data-testid="dashboard-sessions-empty"]') + expect(empty).toBeTruthy() + expect(empty?.textContent).toContain("dashboard:sessions.noSessions") + }) + + it("renders session rows for each session", () => { + const sessions = [ + makeSession({ taskId: "task-A", title: "Session A" }), + makeSession({ taskId: "task-B", title: "Session B" }), + ] + const { container } = render( + , + ) + expect(container.textContent).toContain("Session A") + expect(container.textContent).toContain("Session B") + }) + + it("renders the title header", () => { + const { container } = render( + , + ) + expect(container.textContent).toContain("dashboard:sessions.title") + }) + + it("does not render model filter dropdown", () => { + const sessions = [ + makeSession({ taskId: "task-A", model: "gpt-4" }), + makeSession({ taskId: "task-B", model: "claude-3" }), + ] + const { container } = render( + , + ) + const modelFilter = container.querySelector('[data-testid="dashboard-session-filter-model"]') + expect(modelFilter).toBeFalsy() + }) + + it("does not render provider filter dropdown", () => { + const sessions = [ + makeSession({ taskId: "task-A", provider: "openai" }), + makeSession({ taskId: "task-B", provider: "anthropic" }), + ] + const { container } = render( + , + ) + const providerFilter = container.querySelector('[data-testid="dashboard-session-filter-provider"]') + expect(providerFilter).toBeFalsy() + }) + + it("calls onToggleSession when a session row is clicked", () => { + const onToggleSession = vi.fn() + const sessions = [makeSession({ taskId: "task-A", title: "Click me" })] + const { container } = render( + , + ) + // Find the session row button + const row = container.querySelector('[data-testid="dashboard-session-row"]') + expect(row).toBeTruthy() + fireEvent.click(row!) + expect(onToggleSession).toHaveBeenCalledWith("task-A") + }) + + it("shows loading state when session detail is loading", () => { + const sessions = [makeSession({ taskId: "task-A" })] + const { container } = render( + , + ) + expect(container.textContent).toContain("dashboard:states.loading") + }) + + it("shows error state when session detail fetch failed", () => { + const sessions = [makeSession({ taskId: "task-A" })] + const { container } = render( + , + ) + expect(container.textContent).toContain("Network error") + }) + + it("shows session detail when expanded and loaded", () => { + const sessions = [makeSession({ taskId: "task-A" })] + const detail: SessionDetailType = { + taskId: "task-A", + title: "Test session", + timestamp: Date.now(), + model: "gpt-4", + provider: "openai", + mode: "code", + models: ["gpt-4"], + modes: ["code"], + totalTokens: 1500, + totalCost: 0.05, + callCount: 1, + apiCalls: [], + } + const { container } = render( + , + ) + // The detail's no-calls message should be visible + const noCalls = container.querySelector('[data-testid="dashboard-session-detail-no-calls"]') + expect(noCalls).toBeTruthy() + }) + + it("displays formatted tokens and cost in session row", () => { + const sessions = [makeSession({ taskId: "task-A", totalTokens: 1_500_000, totalCost: 1.23 })] + const { container } = render( + , + ) + expect(container.textContent).toContain("1.50M") + expect(container.textContent).toContain("$1.23") + }) +}) diff --git a/webview-ui/src/components/stats/UsageHeatmap.tsx b/webview-ui/src/components/stats/UsageHeatmap.tsx new file mode 100644 index 0000000000..9fb6c25ace --- /dev/null +++ b/webview-ui/src/components/stats/UsageHeatmap.tsx @@ -0,0 +1,285 @@ +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" + +import { useAppTranslation } from "@/i18n/TranslationContext" +import { vscode } from "@/utils/vscode" +import type { StatsBucket } from "@roo-code/types" + +import { Button, StandardTooltip } from "@/components/ui" + +// ── Types ─────────────────────────────────────────────────────────────────── + +interface DailyActivity { + date: string // YYYY-MM-DD + totalTokens: number + events: number +} + +// ── Heatmap color levels ──────────────────────────────────────────────────── + +/** + * Map a token value to a 0-5 intensity level based on the max value. + * Level 0 = no data, 1-5 = increasing intensity. + */ +function getIntensityLevel(value: number, maxValue: number): number { + if (value === 0 || maxValue === 0) return 0 + const ratio = value / maxValue + if (ratio < 0.2) return 1 + if (ratio < 0.4) return 2 + if (ratio < 0.6) return 3 + if (ratio < 0.8) return 4 + return 5 +} + +const HEATMAP_COLORS: Record = { + 0: "transparent", // No data — white border only + 1: "#c6dbef", // Lightest blue + 2: "#9ecae1", // Light blue + 3: "#6baed6", // Medium blue + 4: "#3182bd", // Dark blue + 5: "#08519c", // Darkest blue +} + +// ── Date helpers ──────────────────────────────────────────────────────────── + +function formatDateKey(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, "0") + const day = String(date.getDate()).padStart(2, "0") + return `${year}-${month}-${day}` +} + +function formatDisplayDate(dateKey: string): string { + try { + const date = new Date(dateKey + "T00:00:00") + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }) + } catch { + return dateKey + } +} + +// ── Range configuration ───────────────────────────────────────────────────── + +type HeatmapRange = "30d" | "60d" | "120d" | "360d" + +const RANGE_DAYS: Record = { + "30d": 30, + "60d": 60, + "120d": 120, + "360d": 360, +} + +const RANGE_OPTIONS: HeatmapRange[] = ["30d", "60d", "120d", "360d"] + +// ── UsageHeatmap ──────────────────────────────────────────────────────────── + +const UsageHeatmap = memo(() => { + const { t } = useAppTranslation() + const [range, setRange] = useState("30d") + const [heatmapBuckets, setHeatmapBuckets] = useState([]) + const [loading, setLoading] = useState(true) + const latestHeatmapRequestIdRef = useRef("") + + // Fetch heatmap data independently from the top-level date picker. + // Sends a getUsageStats message with a "heatmap-" requestId prefix so + // responses can be filtered from DashboardView's own requests. + const fetchHeatmapData = useCallback((rangeArg: HeatmapRange) => { + const days = RANGE_DAYS[rangeArg] + const requestId = `heatmap-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + latestHeatmapRequestIdRef.current = requestId + setLoading(true) + + const from = new Date(Date.now() - days * 86400000) + from.setHours(0, 0, 0, 0) + + let timezone: string + try { + timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" + } catch { + timezone = "UTC" + } + + vscode.postMessage({ + type: "getUsageStats", + requestId, + usageStatsQuery: { + from: from.toISOString(), + timezone, + groupBy: ["day"], + includeCancelled: false, + }, + }) + }, []) + + // Listen for responses to our heatmap requests and perform initial fetch. + useEffect(() => { + const handleMessage = (e: MessageEvent) => { + const message = e.data + + if ( + message.type === "getUsageStatsResponse" && + typeof message.requestId === "string" && + message.requestId.startsWith("heatmap-") && + message.requestId === latestHeatmapRequestIdRef.current + ) { + if (message.usageStatsSnapshot) { + setHeatmapBuckets(message.usageStatsSnapshot.buckets ?? []) + } + setLoading(false) + } + } + + window.addEventListener("message", handleMessage) + fetchHeatmapData(range) // Initial fetch + + return () => window.removeEventListener("message", handleMessage) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const handleRangeChange = useCallback( + (newRange: HeatmapRange) => { + setRange(newRange) + fetchHeatmapData(newRange) + }, + [fetchHeatmapData], + ) + + // Extract daily activity from buckets that have a "day" key + const dailyMap = useMemo(() => { + const map = new Map() + + for (const bucket of heatmapBuckets) { + const dayKey = bucket.key?.day + if (!dayKey) continue + + const existing = map.get(dayKey) + if (existing) { + existing.totalTokens += bucket.totalTokens + existing.events += bucket.events + } else { + map.set(dayKey, { + date: dayKey, + totalTokens: bucket.totalTokens, + events: bucket.events, + }) + } + } + + return map + }, [heatmapBuckets]) + + // Generate the date range for display + const days = useMemo(() => { + const count = RANGE_DAYS[range] + const today = new Date() + today.setHours(0, 0, 0, 0) + const result: DailyActivity[] = [] + + for (let i = count - 1; i >= 0; i--) { + const date = new Date(today) + date.setDate(date.getDate() - i) + const key = formatDateKey(date) + const activity = dailyMap.get(key) + result.push( + activity || { + date: key, + totalTokens: 0, + events: 0, + }, + ) + } + + return result + }, [dailyMap, range]) + + const maxTokens = useMemo(() => { + let max = 0 + for (const day of days) { + if (day.totalTokens > max) max = day.totalTokens + } + return max + }, [days]) + + const hasData = maxTokens > 0 + + // Gap between cells: tighter for longer ranges + const gap = range === "30d" ? "gap-0.5" : "gap-px" + + return ( +
+
+

{t("stats:heatmap.title")}

+
+ {RANGE_OPTIONS.map((option) => ( + + ))} +
+
+ + {loading && !hasData ? ( +
{t("stats:heatmap.loading")}
+ ) : !hasData ? ( +
{t("stats:heatmap.noData")}
+ ) : ( + <> +
+ {days.map((day) => { + const level = getIntensityLevel(day.totalTokens, maxTokens) + return ( + 0 + ? `${formatDisplayDate(day.date)}: ${day.totalTokens.toLocaleString()} tokens (${day.events} requests)` + : `${formatDisplayDate(day.date)}: ${t("stats:heatmap.noData")}` + }> +
+ + ) + })} +
+ + {/* Legend */} +
+ {t("stats:heatmap.less")} + {[0, 1, 2, 3, 4, 5].map((level) => ( +
+ ))} + {t("stats:heatmap.more")} +
+ + )} +
+ ) +}) + +export default UsageHeatmap diff --git a/webview-ui/src/components/stats/__tests__/UsageHeatmap.spec.tsx b/webview-ui/src/components/stats/__tests__/UsageHeatmap.spec.tsx new file mode 100644 index 0000000000..1154d17e91 --- /dev/null +++ b/webview-ui/src/components/stats/__tests__/UsageHeatmap.spec.tsx @@ -0,0 +1,514 @@ +// pnpm --filter @roo-code/vscode-webview test src/components/stats/__tests__/UsageHeatmap.spec.tsx + +import React from "react" +import { render, fireEvent, waitFor } from "@/utils/test-utils" + +import type { StatsBucket } from "@roo-code/types" + +import UsageHeatmap from "../UsageHeatmap" + +// Mock i18n +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + initReactI18next: { + type: "3rdParty", + init: () => {}, + }, + Trans: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +// ── vscode mock ────────────────────────────────────────────────────────────── + +// Captures postMessage calls so tests can inspect the query and simulate +// the extension host's response. +const postMessageMock = vi.fn() +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: (msg: unknown) => postMessageMock(msg), + }, +})) + +// ── Test helpers ───────────────────────────────────────────────────────────── + +/** + * Simulates the extension host responding to a getUsageStats request. + * Finds the latest requestId from the captured postMessage calls and + * dispatches a matching getUsageStatsResponse MessageEvent on window. + */ +function simulateStatsResponse(buckets: StatsBucket[]) { + const calls = postMessageMock.mock.calls + expect(calls.length).toBeGreaterThan(0) + + const lastCall = calls[calls.length - 1][0] as { requestId: string } + const requestId = lastCall.requestId + + const snapshot = { + query: { from: new Date().toISOString(), timezone: "UTC", groupBy: ["day"], includeCancelled: false }, + generatedAt: new Date().toISOString(), + buckets, + totals: buckets.reduce( + (acc, b) => { + acc.totalTokens += b.totalTokens + acc.events += b.events + return acc + }, + { totalTokens: 0, events: 0 } as Record, + ), + coverage: { firstEventAt: undefined, lastEventAt: undefined }, + } + + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "getUsageStatsResponse", + requestId, + usageStatsSnapshot: snapshot, + }, + }), + ) +} + +// ── Test fixtures ──────────────────────────────────────────────────────────── + +/** + * Returns a YYYY-MM-DD key for N days ago relative to today. + */ +function daysAgoKey(daysAgo: number): string { + const date = new Date() + date.setHours(0, 0, 0, 0) + date.setDate(date.getDate() - daysAgo) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, "0") + const day = String(date.getDate()).padStart(2, "0") + return `${year}-${month}-${day}` +} + +function makeBucket(overrides: Partial = {}): StatsBucket { + return { + key: {}, + events: 1, + completedCalls: 1, + failedCalls: 0, + cancelledCalls: 0, + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + totalTokens: 1500, + costUsd: 0.01, + unknownEventCount: 0, + ...overrides, + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("UsageHeatmap", () => { + beforeEach(() => { + postMessageMock.mockClear() + }) + + it("renders the heatmap container with title", () => { + const { container } = render() + + const heatmap = container.querySelector('[data-testid="usage-heatmap"]') + expect(heatmap).toBeTruthy() + expect(heatmap?.textContent).toContain("stats:heatmap.title") + }) + + it("renders no-data message when buckets are empty", async () => { + const { container } = render() + + simulateStatsResponse([]) + + await waitFor(() => { + const heatmap = container.querySelector('[data-testid="usage-heatmap"]') + expect(heatmap?.textContent).toContain("stats:heatmap.noData") + }) + }) + + it("renders no-data message when all buckets have zero totalTokens", async () => { + const buckets = [ + makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 0, events: 0 }), + makeBucket({ key: { day: daysAgoKey(1) }, totalTokens: 0, events: 0 }), + ] + + const { container } = render() + + simulateStatsResponse(buckets) + + await waitFor(() => { + const heatmap = container.querySelector('[data-testid="usage-heatmap"]') + expect(heatmap?.textContent).toContain("stats:heatmap.noData") + }) + }) + + it("renders heatmap grid when data exists", async () => { + const buckets = [ + makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 5000, events: 3 }), + makeBucket({ key: { day: daysAgoKey(1) }, totalTokens: 3000, events: 2 }), + ] + + const { container } = render() + + simulateStatsResponse(buckets) + + await waitFor(() => { + // noData message should not be displayed + const heatmap = container.querySelector('[data-testid="usage-heatmap"]') + expect(heatmap?.textContent).not.toContain("stats:heatmap.noData") + + // Verify grid role attribute + const grid = container.querySelector('[role="img"]') + expect(grid).toBeTruthy() + }) + }) + + it("renders 30d, 60d, 120d, and 360d range toggle buttons", () => { + const { container } = render() + + const btn30d = container.querySelector('[data-testid="heatmap-range-30d"]') + const btn60d = container.querySelector('[data-testid="heatmap-range-60d"]') + const btn120d = container.querySelector('[data-testid="heatmap-range-120d"]') + const btn360d = container.querySelector('[data-testid="heatmap-range-360d"]') + + expect(btn30d).toBeTruthy() + expect(btn60d).toBeTruthy() + expect(btn120d).toBeTruthy() + expect(btn360d).toBeTruthy() + expect(btn30d?.textContent).toContain("stats:heatmap.30d") + expect(btn60d?.textContent).toContain("stats:heatmap.60d") + expect(btn120d?.textContent).toContain("stats:heatmap.120d") + expect(btn360d?.textContent).toContain("stats:heatmap.360d") + }) + + it("defaults to 30d range", async () => { + const buckets = [makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 1000, events: 1 })] + + const { container } = render() + + simulateStatsResponse(buckets) + + // In 30d mode, 30 date cells are generated + await waitFor(() => { + const cells = container.querySelectorAll('[role="img"] [aria-label]') + expect(cells.length).toBe(30) + }) + }) + + it("switches to 60d range when 60d button is clicked", async () => { + const buckets = [makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 1000, events: 1 })] + + const { container } = render() + + simulateStatsResponse(buckets) + + // Wait for initial data to load + await waitFor(() => { + expect(container.querySelectorAll('[role="img"] [aria-label]').length).toBe(30) + }) + + const btn60d = container.querySelector('[data-testid="heatmap-range-60d"]') as HTMLButtonElement + fireEvent.click(btn60d) + + // Simulate response for the 60d request + simulateStatsResponse(buckets) + + // In 60d mode, 60 date cells are generated + await waitFor(() => { + expect(container.querySelectorAll('[role="img"] [aria-label]').length).toBe(60) + }) + }) + + it("switches back to 30d range when 30d button is clicked after 60d", async () => { + const buckets = [makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 1000, events: 1 })] + + const { container } = render() + + simulateStatsResponse(buckets) + + // Wait for initial data to load + await waitFor(() => { + expect(container.querySelectorAll('[role="img"] [aria-label]').length).toBe(30) + }) + + // Switch to 60d + const btn60d = container.querySelector('[data-testid="heatmap-range-60d"]') as HTMLButtonElement + fireEvent.click(btn60d) + simulateStatsResponse(buckets) + + await waitFor(() => { + expect(container.querySelectorAll('[role="img"] [aria-label]').length).toBe(60) + }) + + // Switch back to 30d + const btn30d = container.querySelector('[data-testid="heatmap-range-30d"]') as HTMLButtonElement + fireEvent.click(btn30d) + simulateStatsResponse(buckets) + + await waitFor(() => { + expect(container.querySelectorAll('[role="img"] [aria-label]').length).toBe(30) + }) + }) + + it("renders legend with less/more labels when data exists", async () => { + const buckets = [makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 1000, events: 1 })] + + const { container } = render() + + simulateStatsResponse(buckets) + + await waitFor(() => { + const heatmap = container.querySelector('[data-testid="usage-heatmap"]') + expect(heatmap?.textContent).toContain("stats:heatmap.less") + expect(heatmap?.textContent).toContain("stats:heatmap.more") + }) + }) + + it("does not render legend when no data exists", async () => { + const { container } = render() + + simulateStatsResponse([]) + + await waitFor(() => { + const heatmap = container.querySelector('[data-testid="usage-heatmap"]') + // Only noData message present, no legend + expect(heatmap?.textContent).toContain("stats:heatmap.noData") + expect(heatmap?.textContent).not.toContain("stats:heatmap.less") + expect(heatmap?.textContent).not.toContain("stats:heatmap.more") + }) + }) + + it("aggregates multiple buckets with the same day key", async () => { + const dayKey = daysAgoKey(0) + const buckets = [ + makeBucket({ key: { day: dayKey }, totalTokens: 1000, events: 1 }), + makeBucket({ key: { day: dayKey }, totalTokens: 2000, events: 2 }), + ] + + const { container } = render() + + simulateStatsResponse(buckets) + + // Tokens for the same day key should be summed to 3000 + // Verify the aria-label of today's cell + await waitFor(() => { + const cells = container.querySelectorAll('[role="img"] [aria-label]') + const todayCell = Array.from(cells).find((cell) => { + const aria = cell.getAttribute("aria-label") ?? "" + return aria.startsWith(dayKey) + }) + expect(todayCell).toBeTruthy() + expect(todayCell?.getAttribute("aria-label")).toContain("3000") + expect(todayCell?.getAttribute("aria-label")).toContain("3") + }) + }) + + it("ignores buckets without a day key", async () => { + const buckets = [ + makeBucket({ key: { provider: "anthropic" }, totalTokens: 1000, events: 1 }), + makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 2000, events: 2 }), + ] + + const { container } = render() + + simulateStatsResponse(buckets) + + // Buckets without a day key are ignored, so there is 1 valid entry + // However 2000 > 0, so hasData = true + await waitFor(() => { + const heatmap = container.querySelector('[data-testid="usage-heatmap"]') + expect(heatmap?.textContent).not.toContain("stats:heatmap.noData") + }) + }) + + it("renders aria-label with date and token count for each cell", async () => { + const dayKey = daysAgoKey(0) + const buckets = [makeBucket({ key: { day: dayKey }, totalTokens: 5000, events: 4 })] + + const { container } = render() + + simulateStatsResponse(buckets) + + await waitFor(() => { + const cells = container.querySelectorAll('[role="img"] [aria-label]') + const todayCell = Array.from(cells).find((cell) => { + const aria = cell.getAttribute("aria-label") ?? "" + return aria.startsWith(dayKey) + }) + expect(todayCell).toBeTruthy() + const aria = todayCell?.getAttribute("aria-label") ?? "" + expect(aria).toContain(dayKey) + expect(aria).toContain("5000") + }) + }) + + it("renders aria-label with no-data for zero-token days", async () => { + const { container } = render() + + simulateStatsResponse([]) + + await waitFor(() => { + // In noData state, the grid is not rendered + const grid = container.querySelector('[role="img"]') + expect(grid).toBeFalsy() + }) + }) + + it("uses tighter gap in 360d mode", async () => { + const buckets = [makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 1000, events: 1 })] + + const { container } = render() + + simulateStatsResponse(buckets) + + // Wait for initial data + await waitFor(() => { + expect(container.querySelector('[role="img"]')).toBeTruthy() + }) + + // Switch to 360d mode + const btn360d = container.querySelector('[data-testid="heatmap-range-360d"]') as HTMLButtonElement + fireEvent.click(btn360d) + simulateStatsResponse(buckets) + + await waitFor(() => { + // In 360d mode, gap-px class is applied + const grid = container.querySelector('[role="img"]') + expect(grid).toBeTruthy() + expect(grid?.className).toContain("gap-px") + }) + }) + + it("uses gap-0.5 in 30d mode", async () => { + const buckets = [makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 1000, events: 1 })] + + const { container } = render() + + simulateStatsResponse(buckets) + + // Default 30d mode + await waitFor(() => { + const grid = container.querySelector('[role="img"]') + expect(grid).toBeTruthy() + // In 30d mode, gap-0.5 class is applied + expect(grid?.className).toContain("gap-0.5") + }) + }) + + it("computes intensity levels based on max token value", async () => { + const buckets = [ + makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 4000, events: 4 }), // 100% → level 5 + makeBucket({ key: { day: daysAgoKey(1) }, totalTokens: 1000, events: 1 }), // 25% → level 1 + ] + + const { container } = render() + + simulateStatsResponse(buckets) + + await waitFor(() => { + // Data should be rendered + const heatmap = container.querySelector('[data-testid="usage-heatmap"]') + expect(heatmap?.textContent).not.toContain("stats:heatmap.noData") + + // Legend should be rendered (6 level colors: 0-5) + const legendCells = container.querySelectorAll(".w-3.h-3.rounded-sm") + expect(legendCells.length).toBe(6) + }) + }) + + it("handles buckets with day key but zero events", async () => { + const buckets = [makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 0, events: 0 })] + + const { container } = render() + + simulateStatsResponse(buckets) + + // totalTokens is 0, so hasData = false + await waitFor(() => { + const heatmap = container.querySelector('[data-testid="usage-heatmap"]') + expect(heatmap?.textContent).toContain("stats:heatmap.noData") + }) + }) + + it("renders grid with correct column count for 30d mode", async () => { + const buckets = [makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 1000, events: 1 })] + + const { container } = render() + + simulateStatsResponse(buckets) + + await waitFor(() => { + const grid = container.querySelector('[role="img"]') + expect(grid).toBeTruthy() + // 30d mode: 30 cells / 7 rows = 5 columns (ceil(30/7) = 5) + // CSS property is rendered in kebab-case + const style = grid?.getAttribute("style") ?? "" + expect(style.toLowerCase()).toContain("grid-template-columns") + expect(style).toContain("repeat(5") + }) + }) + + it("renders grid with correct column count for 60d mode", async () => { + const buckets = [makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 1000, events: 1 })] + + const { container } = render() + + simulateStatsResponse(buckets) + + // Wait for initial data + await waitFor(() => { + expect(container.querySelector('[role="img"]')).toBeTruthy() + }) + + const btn60d = container.querySelector('[data-testid="heatmap-range-60d"]') as HTMLButtonElement + fireEvent.click(btn60d) + simulateStatsResponse(buckets) + + await waitFor(() => { + const grid = container.querySelector('[role="img"]') + expect(grid).toBeTruthy() + // 60d mode: 60 cells / 7 rows = 9 columns (ceil(60/7) = 9) + const style = grid?.getAttribute("style") ?? "" + expect(style.toLowerCase()).toContain("grid-template-columns") + expect(style).toContain("repeat(9") + }) + }) + + it("sends getUsageStats message on mount with heatmap- requestId prefix", () => { + render() + + expect(postMessageMock).toHaveBeenCalledTimes(1) + const msg = postMessageMock.mock.calls[0][0] + expect(msg.type).toBe("getUsageStats") + expect(msg.requestId).toMatch(/^heatmap-/) + expect(msg.usageStatsQuery.groupBy).toEqual(["day"]) + expect(msg.usageStatsQuery.includeCancelled).toBe(false) + }) + + it("sends a new getUsageStats message when range changes", async () => { + const buckets = [makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 1000, events: 1 })] + + const { container } = render() + + simulateStatsResponse(buckets) + + await waitFor(() => { + expect(container.querySelector('[role="img"]')).toBeTruthy() + }) + + // Clear mock to count only the new request + postMessageMock.mockClear() + + const btn60d = container.querySelector('[data-testid="heatmap-range-60d"]') as HTMLButtonElement + fireEvent.click(btn60d) + + expect(postMessageMock).toHaveBeenCalledTimes(1) + const msg = postMessageMock.mock.calls[0][0] + expect(msg.type).toBe("getUsageStats") + expect(msg.requestId).toMatch(/^heatmap-/) + }) +}) diff --git a/webview-ui/src/i18n/locales/ca/dashboard.json b/webview-ui/src/i18n/locales/ca/dashboard.json new file mode 100644 index 0000000000..f0fdf8a4f0 --- /dev/null +++ b/webview-ui/src/i18n/locales/ca/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "Tauler", + "done": "Enrere", + "range": { + "today": "Avui", + "7d": "7 dies", + "30d": "30 dies", + "custom": "Personalitzat", + "all": "Tot" + }, + "summary": { + "totalTokens": "Tokens totals", + "inputTokens": "Tokens d'entrada", + "outputTokens": "Tokens de sortida", + "cacheTokens": "Tokens de memòria cau", + "cost": "Cost" + }, + "states": { + "loading": "S'està carregant...", + "error": "No s'han pogut carregar les estadístiques", + "empty": "Encara no hi ha dades d'ús", + "emptyHint": "Inicieu una conversa per veure les estadístiques" + }, + "actions": { + "refresh": "Actualitza", + "exportJson": "Exporta JSON", + "exportCsv": "Exporta CSV", + "clear": "Esborra les estadístiques" + }, + "breakdown": { + "title": "Desglossament", + "model": "Model", + "provider": "Proveïdor", + "mode": "Mode", + "events": "Esdeveniments", + "inputTokens": "Entrada", + "outputTokens": "Sortida", + "cacheReadTokens": "Lectura cau", + "cacheWriteTokens": "Escriptura cau", + "reasoningTokens": "Raonament", + "totalTokens": "Total", + "costUsd": "Cost", + "unknown": "Desconegut" + }, + "coverage": { + "title": "Cobertura de dades", + "liveFrom": "En directe des de", + "lastUpdated": "Última actualització", + "backfilledEvents": "Esdeveniments retroactius", + "paused": "Enregistrament en pausa (s'ha assolit el límit d'emmagatzematge)" + }, + "clearDialog": { + "title": "Esborra les estadístiques", + "description": "Esteu segur que voleu esborrar totes les estadístiques d'ús? Aquesta acció no es pot desfer.", + "cancel": "Cancel·la", + "confirm": "Esborra" + }, + "customRange": { + "from": "Des de", + "to": "Fins a" + }, + "sessions": { + "title": "Sessions", + "noSessions": "No hi ha sessions en aquest període", + "filterModel": "Tots els models", + "filterProvider": "Tots els proveïdors", + "callCount": "{{count}} trucades" + }, + "sessionDetail": { + "summary": "Resum de la sessió", + "apiCalls": "Trucades API", + "noApiCalls": "No hi ha trucades API registrades", + "input": "Entrada", + "output": "Sortida", + "cost": "Cost", + "model": "Model", + "mode": "Mode", + "time": "Hora", + "status": "Estat" + }, + "time": { + "justNow": "ara mateix", + "minutesAgo": "fa {{count}} min", + "hoursAgo": "fa {{count}} h", + "yesterday": "ahir", + "daysAgo": "fa {{count}} dies" + } +} diff --git a/webview-ui/src/i18n/locales/ca/stats.json b/webview-ui/src/i18n/locales/ca/stats.json new file mode 100644 index 0000000000..f494734ef4 --- /dev/null +++ b/webview-ui/src/i18n/locales/ca/stats.json @@ -0,0 +1,82 @@ +{ + "title": "Estadístiques d'ús", + "done": "Torna al xat", + "range": { + "today": "Avui", + "7d": "Últims 7 dies", + "30d": "Últims 30 dies", + "all": "Tot el període" + }, + "summary": { + "totalTokens": "Tokens totals", + "inputTokens": "Tokens d'entrada", + "outputTokens": "Tokens de sortida", + "cacheTokens": "Tokens de memòria cau", + "costUsd": "Cost total" + }, + "breakdown": { + "title": "Desglossament", + "groupBy": "Agrupa per", + "model": "Model", + "provider": "Proveïdor", + "mode": "Mode", + "status": "Estat", + "day": "Dia", + "week": "Setmana", + "month": "Mes", + "events": "Esdeveniments", + "completed": "Completats", + "failed": "Fallits", + "cancelled": "Cancel·lats", + "inputTokens": "Entrada", + "outputTokens": "Sortida", + "cacheReadTokens": "Lectura de memòria cau", + "cacheWriteTokens": "Escriptura de memòria cau", + "reasoningTokens": "Raonament", + "totalTokens": "Total", + "costUsd": "Cost (USD)", + "unknown": "Desconegut", + "empty": "Sense dades d'ús per a aquest interval." + }, + "heatmap": { + "title": "Activitat diària", + "30d": "30 dies", + "60d": "60 dies", + "120d": "120 dies", + "360d": "360 dies", + "less": "Menys", + "more": "Més", + "noData": "Sense dades", + "loading": "Carregant..." + }, + "coverage": { + "title": "Cobertura de dades", + "liveFrom": "Enregistrament des de", + "backfilledEvents": "Esdeveniments retroactius", + "paused": "L'enregistrament està en pausa", + "notAvailable": "No disponible" + }, + "actions": { + "exportJson": "Exporta JSON", + "exportCsv": "Exporta CSV", + "clear": "Esborra totes les dades", + "refresh": "Actualitza" + }, + "clearDialog": { + "title": "Voleu esborrar totes les estadístiques d'ús?", + "description": "Això eliminarà permanentment tots els esdeveniments d'ús registrats de l'emmagatzematge local. Aquesta acció no es pot desfer.", + "confirm": "Elimina", + "cancel": "Cancel·la" + }, + "states": { + "loading": "Carregant estadístiques...", + "error": "No s'han pogut carregar les estadístiques", + "empty": "Encara no hi ha dades d'ús. Les estadístiques apareixeran aquí després de fer crides a l'API LLM.", + "emptyHint": "Proveu d'enviar un missatge al vostre assistent d'IA per començar a recopilar dades d'ús." + }, + "source": { + "provider": "Reportat pel proveïdor", + "estimated": "Estimat", + "backfilled": "Retroactiu" + } +} diff --git a/webview-ui/src/i18n/locales/de/dashboard.json b/webview-ui/src/i18n/locales/de/dashboard.json new file mode 100644 index 0000000000..719ae7c10c --- /dev/null +++ b/webview-ui/src/i18n/locales/de/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "Dashboard", + "done": "Zurück", + "range": { + "today": "Heute", + "7d": "7 Tage", + "30d": "30 Tage", + "custom": "Benutzerdefiniert", + "all": "Alle" + }, + "summary": { + "totalTokens": "Token gesamt", + "inputTokens": "Eingabe-Token", + "outputTokens": "Ausgabe-Token", + "cacheTokens": "Cache-Token", + "cost": "Kosten" + }, + "states": { + "loading": "Wird geladen...", + "error": "Statistiken konnten nicht geladen werden", + "empty": "Noch keine Nutzungsdaten vorhanden", + "emptyHint": "Starten Sie eine Unterhaltung, um Statistiken anzuzeigen" + }, + "actions": { + "refresh": "Aktualisieren", + "exportJson": "JSON exportieren", + "exportCsv": "CSV exportieren", + "clear": "Statistiken löschen" + }, + "breakdown": { + "title": "Aufschlüsselung", + "model": "Modell", + "provider": "Anbieter", + "mode": "Modus", + "events": "Ereignisse", + "inputTokens": "Eingabe", + "outputTokens": "Ausgabe", + "cacheReadTokens": "Cache-Lese", + "cacheWriteTokens": "Cache-Schreib", + "reasoningTokens": "Reasoning", + "totalTokens": "Gesamt", + "costUsd": "Kosten", + "unknown": "Unbekannt" + }, + "coverage": { + "title": "Datenabdeckung", + "liveFrom": "Live seit", + "lastUpdated": "Zuletzt aktualisiert", + "backfilledEvents": "Zurückgefüllte Ereignisse", + "paused": "Aufzeichnung pausiert (Speicherlimit erreicht)" + }, + "clearDialog": { + "title": "Statistiken löschen", + "description": "Möchten Sie wirklich alle Nutzungsstatistiken löschen? Diese Aktion kann nicht rückgängig gemacht werden.", + "cancel": "Abbrechen", + "confirm": "Löschen" + }, + "customRange": { + "from": "Von", + "to": "Bis" + }, + "sessions": { + "title": "Sitzungen", + "noSessions": "Keine Sitzungen in diesem Zeitraum", + "filterModel": "Alle Modelle", + "filterProvider": "Alle Anbieter", + "callCount": "{{count}} Aufrufe" + }, + "sessionDetail": { + "summary": "Sitzungsübersicht", + "apiCalls": "API-Aufrufe", + "noApiCalls": "Keine API-Aufrufe aufgezeichnet", + "input": "Eingabe", + "output": "Ausgabe", + "cost": "Kosten", + "model": "Modell", + "mode": "Modus", + "time": "Zeit", + "status": "Status" + }, + "time": { + "justNow": "gerade eben", + "minutesAgo": "vor {{count}} Min.", + "hoursAgo": "vor {{count}} Std.", + "yesterday": "gestern", + "daysAgo": "vor {{count}} Tagen" + } +} diff --git a/webview-ui/src/i18n/locales/de/stats.json b/webview-ui/src/i18n/locales/de/stats.json new file mode 100644 index 0000000000..8824adddd9 --- /dev/null +++ b/webview-ui/src/i18n/locales/de/stats.json @@ -0,0 +1,82 @@ +{ + "title": "Nutzungsstatistiken", + "done": "Zurück zum Chat", + "range": { + "today": "Heute", + "7d": "Letzte 7 Tage", + "30d": "Letzte 30 Tage", + "all": "Gesamter Zeitraum" + }, + "summary": { + "totalTokens": "Token gesamt", + "inputTokens": "Eingabe-Token", + "outputTokens": "Ausgabe-Token", + "cacheTokens": "Cache-Token", + "costUsd": "Gesamtkosten" + }, + "breakdown": { + "title": "Aufschlüsselung", + "groupBy": "Gruppieren nach", + "model": "Modell", + "provider": "Anbieter", + "mode": "Modus", + "status": "Status", + "day": "Tag", + "week": "Woche", + "month": "Monat", + "events": "Ereignisse", + "completed": "Abgeschlossen", + "failed": "Fehlgeschlagen", + "cancelled": "Abgebrochen", + "inputTokens": "Eingabe", + "outputTokens": "Ausgabe", + "cacheReadTokens": "Cache-Lesevorgänge", + "cacheWriteTokens": "Cache-Schreibvorgänge", + "reasoningTokens": "Reasoning", + "totalTokens": "Gesamt", + "costUsd": "Kosten (USD)", + "unknown": "Unbekannt", + "empty": "Keine Nutzungsdaten für diesen Bereich." + }, + "heatmap": { + "title": "Tägliche Aktivität", + "30d": "30 Tage", + "60d": "60 Tage", + "120d": "120 Tage", + "360d": "360 Tage", + "less": "Weniger", + "more": "Mehr", + "noData": "Keine Daten", + "loading": "Wird geladen..." + }, + "coverage": { + "title": "Datenabdeckung", + "liveFrom": "Aufzeichnung seit", + "backfilledEvents": "Rückwirkend erfasste Ereignisse", + "paused": "Aufzeichnung ist pausiert", + "notAvailable": "Nicht verfügbar" + }, + "actions": { + "exportJson": "JSON exportieren", + "exportCsv": "CSV exportieren", + "clear": "Alle Daten löschen", + "refresh": "Aktualisieren" + }, + "clearDialog": { + "title": "Alle Nutzungsstatistiken löschen?", + "description": "Dadurch werden alle aufgezeichneten Nutzungsereignisse aus dem lokalen Speicher dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", + "confirm": "Löschen", + "cancel": "Abbrechen" + }, + "states": { + "loading": "Statistiken werden geladen...", + "error": "Statistiken konnten nicht geladen werden", + "empty": "Noch keine Nutzungsdaten. Statistiken werden hier angezeigt, nachdem Sie LLM-API-Aufrufe getätigt haben.", + "emptyHint": "Senden Sie eine Nachricht an Ihren KI-Assistenten, um mit der Erfassung von Nutzungsdaten zu beginnen." + }, + "source": { + "provider": "Vom Anbieter gemeldet", + "estimated": "Geschätzt", + "backfilled": "Rückwirkend" + } +} diff --git a/webview-ui/src/i18n/locales/en/dashboard.json b/webview-ui/src/i18n/locales/en/dashboard.json new file mode 100644 index 0000000000..395988bb7f --- /dev/null +++ b/webview-ui/src/i18n/locales/en/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "Dashboard", + "done": "Back", + "range": { + "today": "Today", + "7d": "7 Days", + "30d": "30 Days", + "custom": "Custom", + "all": "All" + }, + "summary": { + "totalTokens": "Total Tokens", + "inputTokens": "Input Tokens", + "outputTokens": "Output Tokens", + "cacheTokens": "Cache Tokens", + "cost": "Cost" + }, + "states": { + "loading": "Loading...", + "error": "Failed to load statistics", + "empty": "No usage data yet", + "emptyHint": "Start a conversation to see statistics" + }, + "actions": { + "refresh": "Refresh", + "exportJson": "Export JSON", + "exportCsv": "Export CSV", + "clear": "Clear Statistics" + }, + "breakdown": { + "title": "Breakdown", + "model": "Model", + "provider": "Provider", + "mode": "Mode", + "events": "Events", + "inputTokens": "Input", + "outputTokens": "Output", + "cacheReadTokens": "Cache Read", + "cacheWriteTokens": "Cache Write", + "reasoningTokens": "Reasoning", + "totalTokens": "Total", + "costUsd": "Cost", + "unknown": "Unknown" + }, + "coverage": { + "title": "Data Coverage", + "liveFrom": "Live from", + "lastUpdated": "Last Updated", + "backfilledEvents": "Backfilled events", + "paused": "Recording paused (storage limit reached)" + }, + "clearDialog": { + "title": "Clear Statistics", + "description": "Are you sure you want to clear all usage statistics? This action cannot be undone.", + "cancel": "Cancel", + "confirm": "Clear" + }, + "customRange": { + "from": "From", + "to": "To" + }, + "sessions": { + "title": "Sessions", + "noSessions": "No sessions in this time range", + "filterModel": "All Models", + "filterProvider": "All Providers", + "callCount": "{{count}} calls" + }, + "sessionDetail": { + "summary": "Session Summary", + "apiCalls": "API Calls", + "noApiCalls": "No API calls recorded", + "input": "Input", + "output": "Output", + "cost": "Cost", + "model": "Model", + "mode": "Mode", + "time": "Time", + "status": "Status" + }, + "time": { + "justNow": "just now", + "minutesAgo": "{{count}} min ago", + "hoursAgo": "{{count}} hr ago", + "yesterday": "yesterday", + "daysAgo": "{{count}} days ago" + } +} diff --git a/webview-ui/src/i18n/locales/en/stats.json b/webview-ui/src/i18n/locales/en/stats.json new file mode 100644 index 0000000000..09ed221405 --- /dev/null +++ b/webview-ui/src/i18n/locales/en/stats.json @@ -0,0 +1,82 @@ +{ + "title": "Usage Statistics", + "done": "Back to Chat", + "range": { + "today": "Today", + "7d": "Last 7 Days", + "30d": "Last 30 Days", + "all": "All Time" + }, + "summary": { + "totalTokens": "Total Tokens", + "inputTokens": "Input Tokens", + "outputTokens": "Output Tokens", + "cacheTokens": "Cache Tokens", + "costUsd": "Total Cost" + }, + "breakdown": { + "title": "Breakdown", + "groupBy": "Group by", + "model": "Model", + "provider": "Provider", + "mode": "Mode", + "status": "Status", + "day": "Day", + "week": "Week", + "month": "Month", + "events": "Events", + "completed": "Completed", + "failed": "Failed", + "cancelled": "Cancelled", + "inputTokens": "Input", + "outputTokens": "Output", + "cacheReadTokens": "Cache Read", + "cacheWriteTokens": "Cache Write", + "reasoningTokens": "Reasoning", + "totalTokens": "Total", + "costUsd": "Cost (USD)", + "unknown": "Unknown", + "empty": "No usage data for this range." + }, + "heatmap": { + "title": "Daily Activity", + "30d": "30 Days", + "60d": "60 Days", + "120d": "120 Days", + "360d": "360 Days", + "less": "Less", + "more": "More", + "noData": "No data", + "loading": "Loading..." + }, + "coverage": { + "title": "Data Coverage", + "liveFrom": "Recording since", + "backfilledEvents": "Backfilled events", + "paused": "Recording is paused", + "notAvailable": "Not available" + }, + "actions": { + "exportJson": "Export JSON", + "exportCsv": "Export CSV", + "clear": "Clear All Data", + "refresh": "Refresh" + }, + "clearDialog": { + "title": "Clear All Usage Statistics?", + "description": "This will permanently delete all recorded usage events from local storage. This action cannot be undone.", + "confirm": "Delete", + "cancel": "Cancel" + }, + "states": { + "loading": "Loading statistics...", + "error": "Failed to load statistics", + "empty": "No usage data yet. Statistics will appear here after you make LLM API calls.", + "emptyHint": "Try sending a message to your AI assistant to start collecting usage data." + }, + "source": { + "provider": "Provider-reported", + "estimated": "Estimated", + "backfilled": "Backfilled" + } +} diff --git a/webview-ui/src/i18n/locales/es/dashboard.json b/webview-ui/src/i18n/locales/es/dashboard.json new file mode 100644 index 0000000000..ff4fee3514 --- /dev/null +++ b/webview-ui/src/i18n/locales/es/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "Panel", + "done": "Atrás", + "range": { + "today": "Hoy", + "7d": "7 días", + "30d": "30 días", + "custom": "Personalizado", + "all": "Todo" + }, + "summary": { + "totalTokens": "Tokens totales", + "inputTokens": "Tokens de entrada", + "outputTokens": "Tokens de salida", + "cacheTokens": "Tokens de caché", + "cost": "Costo" + }, + "states": { + "loading": "Cargando...", + "error": "Error al cargar las estadísticas", + "empty": "Aún no hay datos de uso", + "emptyHint": "Inicie una conversación para ver las estadísticas" + }, + "actions": { + "refresh": "Actualizar", + "exportJson": "Exportar JSON", + "exportCsv": "Exportar CSV", + "clear": "Borrar estadísticas" + }, + "breakdown": { + "title": "Desglose", + "model": "Modelo", + "provider": "Proveedor", + "mode": "Modo", + "events": "Eventos", + "inputTokens": "Entrada", + "outputTokens": "Salida", + "cacheReadTokens": "Lectura caché", + "cacheWriteTokens": "Escritura caché", + "reasoningTokens": "Razonamiento", + "totalTokens": "Total", + "costUsd": "Costo", + "unknown": "Desconocido" + }, + "coverage": { + "title": "Cobertura de datos", + "liveFrom": "En vivo desde", + "lastUpdated": "Última actualización", + "backfilledEvents": "Eventos retroactivos", + "paused": "Grabación en pausa (límite de almacenamiento alcanzado)" + }, + "clearDialog": { + "title": "Borrar estadísticas", + "description": "¿Está seguro de que desea borrar todas las estadísticas de uso? Esta acción no se puede deshacer.", + "cancel": "Cancelar", + "confirm": "Borrar" + }, + "customRange": { + "from": "Desde", + "to": "Hasta" + }, + "sessions": { + "title": "Sesiones", + "noSessions": "No hay sesiones en este rango de tiempo", + "filterModel": "Todos los modelos", + "filterProvider": "Todos los proveedores", + "callCount": "{{count}} llamadas" + }, + "sessionDetail": { + "summary": "Resumen de sesión", + "apiCalls": "Llamadas API", + "noApiCalls": "No hay llamadas API registradas", + "input": "Entrada", + "output": "Salida", + "cost": "Costo", + "model": "Modelo", + "mode": "Modo", + "time": "Hora", + "status": "Estado" + }, + "time": { + "justNow": "justo ahora", + "minutesAgo": "hace {{count}} min", + "hoursAgo": "hace {{count}} h", + "yesterday": "ayer", + "daysAgo": "hace {{count}} días" + } +} diff --git a/webview-ui/src/i18n/locales/es/stats.json b/webview-ui/src/i18n/locales/es/stats.json new file mode 100644 index 0000000000..34d5c693c0 --- /dev/null +++ b/webview-ui/src/i18n/locales/es/stats.json @@ -0,0 +1,82 @@ +{ + "title": "Estadísticas de uso", + "done": "Volver al chat", + "range": { + "today": "Hoy", + "7d": "Últimos 7 días", + "30d": "Últimos 30 días", + "all": "Todo el periodo" + }, + "summary": { + "totalTokens": "Tokens totales", + "inputTokens": "Tokens de entrada", + "outputTokens": "Tokens de salida", + "cacheTokens": "Tokens de caché", + "costUsd": "Coste total" + }, + "breakdown": { + "title": "Desglose", + "groupBy": "Agrupar por", + "model": "Modelo", + "provider": "Proveedor", + "mode": "Modo", + "status": "Estado", + "day": "Día", + "week": "Semana", + "month": "Mes", + "events": "Eventos", + "completed": "Completados", + "failed": "Fallidos", + "cancelled": "Cancelados", + "inputTokens": "Entrada", + "outputTokens": "Salida", + "cacheReadTokens": "Lectura de caché", + "cacheWriteTokens": "Escritura de caché", + "reasoningTokens": "Razonamiento", + "totalTokens": "Total", + "costUsd": "Coste (USD)", + "unknown": "Desconocido", + "empty": "Sin datos de uso para este rango." + }, + "heatmap": { + "title": "Actividad diaria", + "30d": "30 días", + "60d": "60 días", + "120d": "120 días", + "360d": "360 días", + "less": "Menos", + "more": "Más", + "noData": "Sin datos", + "loading": "Cargando..." + }, + "coverage": { + "title": "Cobertura de datos", + "liveFrom": "Grabando desde", + "backfilledEvents": "Eventos retroactivos", + "paused": "La grabación está pausada", + "notAvailable": "No disponible" + }, + "actions": { + "exportJson": "Exportar JSON", + "exportCsv": "Exportar CSV", + "clear": "Borrar todos los datos", + "refresh": "Actualizar" + }, + "clearDialog": { + "title": "¿Borrar todas las estadísticas de uso?", + "description": "Esto eliminará permanentemente todos los eventos de uso registrados del almacenamiento local. Esta acción no se puede deshacer.", + "confirm": "Eliminar", + "cancel": "Cancelar" + }, + "states": { + "loading": "Cargando estadísticas...", + "error": "Error al cargar las estadísticas", + "empty": "Aún no hay datos de uso. Las estadísticas aparecerán aquí después de realizar llamadas a la API de LLM.", + "emptyHint": "Intente enviar un mensaje a su asistente de IA para comenzar a recopilar datos de uso." + }, + "source": { + "provider": "Reportado por el proveedor", + "estimated": "Estimado", + "backfilled": "Retroactivo" + } +} diff --git a/webview-ui/src/i18n/locales/fr/dashboard.json b/webview-ui/src/i18n/locales/fr/dashboard.json new file mode 100644 index 0000000000..79c3f252e0 --- /dev/null +++ b/webview-ui/src/i18n/locales/fr/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "Tableau de bord", + "done": "Retour", + "range": { + "today": "Aujourd'hui", + "7d": "7 jours", + "30d": "30 jours", + "custom": "Personnalisé", + "all": "Tout" + }, + "summary": { + "totalTokens": "Tokens totaux", + "inputTokens": "Tokens d'entrée", + "outputTokens": "Tokens de sortie", + "cacheTokens": "Tokens de cache", + "cost": "Coût" + }, + "states": { + "loading": "Chargement...", + "error": "Échec du chargement des statistiques", + "empty": "Pas encore de données d'utilisation", + "emptyHint": "Démarrez une conversation pour voir les statistiques" + }, + "actions": { + "refresh": "Actualiser", + "exportJson": "Exporter JSON", + "exportCsv": "Exporter CSV", + "clear": "Effacer les statistiques" + }, + "breakdown": { + "title": "Répartition", + "model": "Modèle", + "provider": "Fournisseur", + "mode": "Mode", + "events": "Événements", + "inputTokens": "Entrée", + "outputTokens": "Sortie", + "cacheReadTokens": "Lecture cache", + "cacheWriteTokens": "Écriture cache", + "reasoningTokens": "Raisonnement", + "totalTokens": "Total", + "costUsd": "Coût", + "unknown": "Inconnu" + }, + "coverage": { + "title": "Couverture des données", + "liveFrom": "En direct depuis", + "lastUpdated": "Dernière mise à jour", + "backfilledEvents": "Événements rétroactivés", + "paused": "Enregistrement en pause (limite de stockage atteinte)" + }, + "clearDialog": { + "title": "Effacer les statistiques", + "description": "Voulez-vous vraiment effacer toutes les statistiques d'utilisation ? Cette action est irréversible.", + "cancel": "Annuler", + "confirm": "Effacer" + }, + "customRange": { + "from": "De", + "to": "À" + }, + "sessions": { + "title": "Sessions", + "noSessions": "Aucune session dans cette période", + "filterModel": "Tous les modèles", + "filterProvider": "Tous les fournisseurs", + "callCount": "{{count}} appels" + }, + "sessionDetail": { + "summary": "Résumé de la session", + "apiCalls": "Appels API", + "noApiCalls": "Aucun appel API enregistré", + "input": "Entrée", + "output": "Sortie", + "cost": "Coût", + "model": "Modèle", + "mode": "Mode", + "time": "Heure", + "status": "Statut" + }, + "time": { + "justNow": "à l'instant", + "minutesAgo": "il y a {{count}} min", + "hoursAgo": "il y a {{count}} h", + "yesterday": "hier", + "daysAgo": "il y a {{count}} jours" + } +} diff --git a/webview-ui/src/i18n/locales/fr/stats.json b/webview-ui/src/i18n/locales/fr/stats.json new file mode 100644 index 0000000000..ecd5b45149 --- /dev/null +++ b/webview-ui/src/i18n/locales/fr/stats.json @@ -0,0 +1,82 @@ +{ + "title": "Statistiques d'utilisation", + "done": "Retour au chat", + "range": { + "today": "Aujourd'hui", + "7d": "7 derniers jours", + "30d": "30 derniers jours", + "all": "Tout l'historique" + }, + "summary": { + "totalTokens": "Total des jetons", + "inputTokens": "Jetons d'entrée", + "outputTokens": "Jetons de sortie", + "cacheTokens": "Jetons de cache", + "costUsd": "Coût total" + }, + "breakdown": { + "title": "Répartition", + "groupBy": "Regrouper par", + "model": "Modèle", + "provider": "Fournisseur", + "mode": "Mode", + "status": "Statut", + "day": "Jour", + "week": "Semaine", + "month": "Mois", + "events": "Événements", + "completed": "Terminés", + "failed": "Échoués", + "cancelled": "Annulés", + "inputTokens": "Entrée", + "outputTokens": "Sortie", + "cacheReadTokens": "Lecture du cache", + "cacheWriteTokens": "Écriture du cache", + "reasoningTokens": "Raisonnement", + "totalTokens": "Total", + "costUsd": "Coût (USD)", + "unknown": "Inconnu", + "empty": "Aucune donnée d'utilisation pour cette période." + }, + "heatmap": { + "title": "Activité quotidienne", + "30d": "30 jours", + "60d": "60 jours", + "120d": "120 jours", + "360d": "360 jours", + "less": "Moins", + "more": "Plus", + "noData": "Aucune donnée", + "loading": "Chargement..." + }, + "coverage": { + "title": "Couverture des données", + "liveFrom": "Enregistrement depuis", + "backfilledEvents": "Événements rétroactifs", + "paused": "L'enregistrement est en pause", + "notAvailable": "Non disponible" + }, + "actions": { + "exportJson": "Exporter JSON", + "exportCsv": "Exporter CSV", + "clear": "Effacer toutes les données", + "refresh": "Actualiser" + }, + "clearDialog": { + "title": "Effacer toutes les statistiques d'utilisation ?", + "description": "Cela supprimera définitivement tous les événements d'utilisation enregistrés du stockage local. Cette action est irréversible.", + "confirm": "Supprimer", + "cancel": "Annuler" + }, + "states": { + "loading": "Chargement des statistiques...", + "error": "Échec du chargement des statistiques", + "empty": "Pas encore de données d'utilisation. Les statistiques apparaîtront ici après vos appels d'API LLM.", + "emptyHint": "Essayez d'envoyer un message à votre assistant IA pour commencer à collecter des données d'utilisation." + }, + "source": { + "provider": "Signalé par le fournisseur", + "estimated": "Estimé", + "backfilled": "Rétroactif" + } +} diff --git a/webview-ui/src/i18n/locales/hi/dashboard.json b/webview-ui/src/i18n/locales/hi/dashboard.json new file mode 100644 index 0000000000..592de718c0 --- /dev/null +++ b/webview-ui/src/i18n/locales/hi/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "डैशबोर्ड", + "done": "वापस", + "range": { + "today": "आज", + "7d": "7 दिन", + "30d": "30 दिन", + "custom": "कस्टम", + "all": "सभी" + }, + "summary": { + "totalTokens": "कुल टोकन", + "inputTokens": "इनपुट टोकन", + "outputTokens": "आउटपुट टोकन", + "cacheTokens": "कैश टोकन", + "cost": "लागत" + }, + "states": { + "loading": "लोड हो रहा है...", + "error": "आँकड़े लोड करने में विफल", + "empty": "अभी तक कोई उपयोग डेटा नहीं", + "emptyHint": "आँकड़े देखने के लिए एक बातचीत शुरू करें" + }, + "actions": { + "refresh": "ताज़ा करें", + "exportJson": "JSON निर्यात करें", + "exportCsv": "CSV निर्यात करें", + "clear": "आँकड़े साफ़ करें" + }, + "breakdown": { + "title": "विवरण", + "model": "मॉडल", + "provider": "प्रदाता", + "mode": "मोड", + "events": "घटनाएँ", + "inputTokens": "इनपुट", + "outputTokens": "आउटपुट", + "cacheReadTokens": "कैश पढ़ें", + "cacheWriteTokens": "कैश लिखें", + "reasoningTokens": "तर्क", + "totalTokens": "कुल", + "costUsd": "लागत", + "unknown": "अज्ञात" + }, + "coverage": { + "title": "डेटा कवरेज", + "liveFrom": "से लाइव", + "lastUpdated": "अंतिम अपडेट", + "backfilledEvents": "बैकफिल की गई घटनाएँ", + "paused": "रिकॉर्डिंग रुकी हुई है (भंडारण सीमा पहुँच गई)" + }, + "clearDialog": { + "title": "आँकड़े साफ़ करें", + "description": "क्या आप वाकई सभी उपयोग आँकड़े साफ़ करना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती।", + "cancel": "रद्द करें", + "confirm": "साफ़ करें" + }, + "customRange": { + "from": "से", + "to": "तक" + }, + "sessions": { + "title": "सत्र", + "noSessions": "इस समय सीमा में कोई सत्र नहीं", + "filterModel": "सभी मॉडल", + "filterProvider": "सभी प्रदाता", + "callCount": "{{count}} कॉल" + }, + "sessionDetail": { + "summary": "सत्र सारांश", + "apiCalls": "API कॉल", + "noApiCalls": "कोई API कॉल रिकॉर्ड नहीं", + "input": "इनपुट", + "output": "आउटपुट", + "cost": "लागत", + "model": "मॉडल", + "mode": "मोड", + "time": "समय", + "status": "स्थिति" + }, + "time": { + "justNow": "अभी", + "minutesAgo": "{{count}} मिनट पहले", + "hoursAgo": "{{count}} घंटे पहले", + "yesterday": "कल", + "daysAgo": "{{count}} दिन पहले" + } +} diff --git a/webview-ui/src/i18n/locales/hi/stats.json b/webview-ui/src/i18n/locales/hi/stats.json new file mode 100644 index 0000000000..ef5760efbe --- /dev/null +++ b/webview-ui/src/i18n/locales/hi/stats.json @@ -0,0 +1,82 @@ +{ + "title": "उपयोग सांख्यिकी", + "done": "चैट पर वापस जाएं", + "range": { + "today": "आज", + "7d": "अंतिम 7 दिन", + "30d": "अंतिम 30 दिन", + "all": "सभी समय" + }, + "summary": { + "totalTokens": "कुल टोकन", + "inputTokens": "इनपुट टोकन", + "outputTokens": "आउटपुट टोकन", + "cacheTokens": "कैश टोकन", + "costUsd": "कुल लागत" + }, + "breakdown": { + "title": "विवरण", + "groupBy": "इसके अनुसार समूहबद्ध करें", + "model": "मॉडल", + "provider": "प्रदाता", + "mode": "मोड", + "status": "स्थिति", + "day": "दिन", + "week": "सप्ताह", + "month": "माह", + "events": "घटनाएं", + "completed": "पूर्ण", + "failed": "विफल", + "cancelled": "रद्द", + "inputTokens": "इनपुट", + "outputTokens": "आउटपुट", + "cacheReadTokens": "कैश पढ़ें", + "cacheWriteTokens": "कैश लिखें", + "reasoningTokens": "तर्क", + "totalTokens": "कुल", + "costUsd": "लागत (USD)", + "unknown": "अज्ञात", + "empty": "इस श्रेणी के लिए कोई उपयोग डेटा नहीं है।" + }, + "heatmap": { + "title": "दैनिक गतिविधि", + "30d": "30 दिन", + "60d": "60 दिन", + "120d": "120 दिन", + "360d": "360 दिन", + "less": "कम", + "more": "अधिक", + "noData": "कोई डेटा नहीं", + "loading": "लोड हो रहा है..." + }, + "coverage": { + "title": "डेटा कवरेज", + "liveFrom": "से रिकॉर्ड कर रहा है", + "backfilledEvents": "पूर्वव्यापी घटनाएं", + "paused": "रिकॉर्डिंग रुकी हुई है", + "notAvailable": "उपलब्ध नहीं" + }, + "actions": { + "exportJson": "JSON निर्यात करें", + "exportCsv": "CSV निर्यात करें", + "clear": "सभी डेटा साफ़ करें", + "refresh": "ताज़ा करें" + }, + "clearDialog": { + "title": "सभी उपयोग सांख्यिकी साफ़ करें?", + "description": "यह स्थानीय भंडारण से सभी दर्ज उपयोग घटनाओं को स्थायी रूप से हटा देगा। इस क्रिया को पूर्ववत नहीं किया जा सकता।", + "confirm": "हटाएं", + "cancel": "रद्द करें" + }, + "states": { + "loading": "सांख्यिकी लोड हो रही है...", + "error": "सांख्यिकी लोड करने में विफल", + "empty": "अभी तक कोई उपयोग डेटा नहीं है। LLM API कॉल करने के बाद सांख्यिकी यहां दिखाई देगी।", + "emptyHint": "उपयोग डेटा एकत्र करना शुरू करने के लिए अपने AI सहायक को एक संदेश भेजने का प्रयास करें।" + }, + "source": { + "provider": "प्रदाता द्वारा रिपोर्ट किया गया", + "estimated": "अनुमानित", + "backfilled": "पूर्वव्यापी" + } +} diff --git a/webview-ui/src/i18n/locales/id/dashboard.json b/webview-ui/src/i18n/locales/id/dashboard.json new file mode 100644 index 0000000000..8d76b79507 --- /dev/null +++ b/webview-ui/src/i18n/locales/id/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "Dasbor", + "done": "Kembali", + "range": { + "today": "Hari ini", + "7d": "7 Hari", + "30d": "30 Hari", + "custom": "Kustom", + "all": "Semua" + }, + "summary": { + "totalTokens": "Total Token", + "inputTokens": "Token Input", + "outputTokens": "Token Output", + "cacheTokens": "Token Cache", + "cost": "Biaya" + }, + "states": { + "loading": "Memuat...", + "error": "Gagal memuat statistik", + "empty": "Belum ada data penggunaan", + "emptyHint": "Mulai percakapan untuk melihat statistik" + }, + "actions": { + "refresh": "Segarkan", + "exportJson": "Ekspor JSON", + "exportCsv": "Ekspor CSV", + "clear": "Hapus Statistik" + }, + "breakdown": { + "title": "Rincian", + "model": "Model", + "provider": "Penyedia", + "mode": "Mode", + "events": "Peristiwa", + "inputTokens": "Input", + "outputTokens": "Output", + "cacheReadTokens": "Baca Cache", + "cacheWriteTokens": "Tulis Cache", + "reasoningTokens": "Penalaran", + "totalTokens": "Total", + "costUsd": "Biaya", + "unknown": "Tidak diketahui" + }, + "coverage": { + "title": "Cakupan Data", + "liveFrom": "Langsung sejak", + "lastUpdated": "Terakhir diperbarui", + "backfilledEvents": "Peristiwa backfill", + "paused": "Perekaman dijeda (batas penyimpanan tercapai)" + }, + "clearDialog": { + "title": "Hapus Statistik", + "description": "Apakah Anda yakin ingin menghapus semua statistik penggunaan? Tindakan ini tidak dapat dibatalkan.", + "cancel": "Batal", + "confirm": "Hapus" + }, + "customRange": { + "from": "Dari", + "to": "Sampai" + }, + "sessions": { + "title": "Sesi", + "noSessions": "Tidak ada sesi dalam rentang waktu ini", + "filterModel": "Semua Model", + "filterProvider": "Semua Penyedia", + "callCount": "{{count}} panggilan" + }, + "sessionDetail": { + "summary": "Ringkasan Sesi", + "apiCalls": "Panggilan API", + "noApiCalls": "Tidak ada panggilan API yang tercatat", + "input": "Input", + "output": "Output", + "cost": "Biaya", + "model": "Model", + "mode": "Mode", + "time": "Waktu", + "status": "Status" + }, + "time": { + "justNow": "baru saja", + "minutesAgo": "{{count}} mnt lalu", + "hoursAgo": "{{count}} jam lalu", + "yesterday": "kemarin", + "daysAgo": "{{count}} hari lalu" + } +} diff --git a/webview-ui/src/i18n/locales/id/stats.json b/webview-ui/src/i18n/locales/id/stats.json new file mode 100644 index 0000000000..75233a6e08 --- /dev/null +++ b/webview-ui/src/i18n/locales/id/stats.json @@ -0,0 +1,82 @@ +{ + "title": "Statistik penggunaan", + "done": "Kembali ke obrolan", + "range": { + "today": "Hari ini", + "7d": "7 hari terakhir", + "30d": "30 hari terakhir", + "all": "Sepanjang waktu" + }, + "summary": { + "totalTokens": "Total token", + "inputTokens": "Token masukan", + "outputTokens": "Token keluaran", + "cacheTokens": "Token cache", + "costUsd": "Total biaya" + }, + "breakdown": { + "title": "Rincian", + "groupBy": "Kelompokkan berdasarkan", + "model": "Model", + "provider": "Penyedia", + "mode": "Mode", + "status": "Status", + "day": "Hari", + "week": "Minggu", + "month": "Bulan", + "events": "Peristiwa", + "completed": "Selesai", + "failed": "Gagal", + "cancelled": "Dibatalkan", + "inputTokens": "Masukan", + "outputTokens": "Keluaran", + "cacheReadTokens": "Baca cache", + "cacheWriteTokens": "Tulis cache", + "reasoningTokens": "Penalaran", + "totalTokens": "Total", + "costUsd": "Biaya (USD)", + "unknown": "Tidak diketahui", + "empty": "Tidak ada data penggunaan untuk rentang ini." + }, + "heatmap": { + "title": "Aktivitas harian", + "30d": "30 hari", + "60d": "60 hari", + "120d": "120 hari", + "360d": "360 hari", + "less": "Lebih sedikit", + "more": "Lebih banyak", + "noData": "Tidak ada data", + "loading": "Memuat..." + }, + "coverage": { + "title": "Cakupan data", + "liveFrom": "Merekam sejak", + "backfilledEvents": "Peristiwa retroaktif", + "paused": "Perekaman dijeda", + "notAvailable": "Tidak tersedia" + }, + "actions": { + "exportJson": "Ekspor JSON", + "exportCsv": "Ekspor CSV", + "clear": "Hapus semua data", + "refresh": "Segarkan" + }, + "clearDialog": { + "title": "Hapus semua statistik penggunaan?", + "description": "Tindakan ini akan menghapus permanen semua peristiwa penggunaan yang tercatat dari penyimpanan lokal. Tindakan ini tidak dapat dibatalkan.", + "confirm": "Hapus", + "cancel": "Batal" + }, + "states": { + "loading": "Memuat statistik...", + "error": "Gagal memuat statistik", + "empty": "Belum ada data penggunaan. Statistik akan muncul di sini setelah Anda melakukan panggilan API LLM.", + "emptyHint": "Coba kirim pesan ke asisten AI Anda untuk mulai mengumpulkan data penggunaan." + }, + "source": { + "provider": "Dilaporkan penyedia", + "estimated": "Perkiraan", + "backfilled": "Retroaktif" + } +} diff --git a/webview-ui/src/i18n/locales/it/dashboard.json b/webview-ui/src/i18n/locales/it/dashboard.json new file mode 100644 index 0000000000..e3228c4624 --- /dev/null +++ b/webview-ui/src/i18n/locales/it/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "Dashboard", + "done": "Indietro", + "range": { + "today": "Oggi", + "7d": "7 giorni", + "30d": "30 giorni", + "custom": "Personalizzato", + "all": "Tutto" + }, + "summary": { + "totalTokens": "Token totali", + "inputTokens": "Token di input", + "outputTokens": "Token di output", + "cacheTokens": "Token cache", + "cost": "Costo" + }, + "states": { + "loading": "Caricamento...", + "error": "Caricamento delle statistiche non riuscito", + "empty": "Nessun dato di utilizzo ancora", + "emptyHint": "Avvia una conversazione per vedere le statistiche" + }, + "actions": { + "refresh": "Aggiorna", + "exportJson": "Esporta JSON", + "exportCsv": "Esporta CSV", + "clear": "Cancella statistiche" + }, + "breakdown": { + "title": "Dettaglio", + "model": "Modello", + "provider": "Provider", + "mode": "Modalità", + "events": "Eventi", + "inputTokens": "Input", + "outputTokens": "Output", + "cacheReadTokens": "Lettura cache", + "cacheWriteTokens": "Scrittura cache", + "reasoningTokens": "Ragionamento", + "totalTokens": "Totale", + "costUsd": "Costo", + "unknown": "Sconosciuto" + }, + "coverage": { + "title": "Copertura dati", + "liveFrom": "In diretta da", + "lastUpdated": "Ultimo aggiornamento", + "backfilledEvents": "Eventi retrodatati", + "paused": "Registrazione in pausa (limite di archiviazione raggiunto)" + }, + "clearDialog": { + "title": "Cancella statistiche", + "description": "Sei sicuro di voler cancellare tutte le statistiche di utilizzo? Questa azione non può essere annullata.", + "cancel": "Annulla", + "confirm": "Cancella" + }, + "customRange": { + "from": "Da", + "to": "A" + }, + "sessions": { + "title": "Sessioni", + "noSessions": "Nessuna sessione in questo intervallo di tempo", + "filterModel": "Tutti i modelli", + "filterProvider": "Tutti i provider", + "callCount": "{{count}} chiamate" + }, + "sessionDetail": { + "summary": "Riepilogo sessione", + "apiCalls": "Chiamate API", + "noApiCalls": "Nessuna chiamata API registrata", + "input": "Input", + "output": "Output", + "cost": "Costo", + "model": "Modello", + "mode": "Modalità", + "time": "Ora", + "status": "Stato" + }, + "time": { + "justNow": "adesso", + "minutesAgo": "{{count}} min fa", + "hoursAgo": "{{count}} ora fa", + "yesterday": "ieri", + "daysAgo": "{{count}} giorni fa" + } +} diff --git a/webview-ui/src/i18n/locales/it/stats.json b/webview-ui/src/i18n/locales/it/stats.json new file mode 100644 index 0000000000..273629ba2c --- /dev/null +++ b/webview-ui/src/i18n/locales/it/stats.json @@ -0,0 +1,82 @@ +{ + "title": "Statistiche di utilizzo", + "done": "Torna alla chat", + "range": { + "today": "Oggi", + "7d": "Ultimi 7 giorni", + "30d": "Ultimi 30 giorni", + "all": "Tutto il periodo" + }, + "summary": { + "totalTokens": "Token totali", + "inputTokens": "Token di input", + "outputTokens": "Token di output", + "cacheTokens": "Token di cache", + "costUsd": "Costo totale" + }, + "breakdown": { + "title": "Dettaglio", + "groupBy": "Raggruppa per", + "model": "Modello", + "provider": "Provider", + "mode": "Modalità", + "status": "Stato", + "day": "Giorno", + "week": "Settimana", + "month": "Mese", + "events": "Eventi", + "completed": "Completati", + "failed": "Falliti", + "cancelled": "Annullati", + "inputTokens": "Input", + "outputTokens": "Output", + "cacheReadTokens": "Lettura cache", + "cacheWriteTokens": "Scrittura cache", + "reasoningTokens": "Ragionamento", + "totalTokens": "Totale", + "costUsd": "Costo (USD)", + "unknown": "Sconosciuto", + "empty": "Nessun dato di utilizzo per questo intervallo." + }, + "heatmap": { + "title": "Attività giornaliera", + "30d": "30 giorni", + "60d": "60 giorni", + "120d": "120 giorni", + "360d": "360 giorni", + "less": "Meno", + "more": "Più", + "noData": "Nessun dato", + "loading": "Caricamento..." + }, + "coverage": { + "title": "Copertura dei dati", + "liveFrom": "Registrazione da", + "backfilledEvents": "Eventi retrodatati", + "paused": "La registrazione è in pausa", + "notAvailable": "Non disponibile" + }, + "actions": { + "exportJson": "Esporta JSON", + "exportCsv": "Esporta CSV", + "clear": "Cancella tutti i dati", + "refresh": "Aggiorna" + }, + "clearDialog": { + "title": "Cancellare tutte le statistiche di utilizzo?", + "description": "Questo eliminerà definitivamente tutti gli eventi di utilizzo registrati dall'archiviazione locale. Questa azione non può essere annullata.", + "confirm": "Elimina", + "cancel": "Annulla" + }, + "states": { + "loading": "Caricamento statistiche...", + "error": "Caricamento delle statistiche non riuscito", + "empty": "Nessun dato di utilizzo ancora disponibile. Le statistiche appariranno qui dopo le chiamate API LLM.", + "emptyHint": "Prova a inviare un messaggio al tuo assistente IA per iniziare a raccogliere i dati di utilizzo." + }, + "source": { + "provider": "Segnalato dal provider", + "estimated": "Stimato", + "backfilled": "Retrodatato" + } +} diff --git a/webview-ui/src/i18n/locales/ja/dashboard.json b/webview-ui/src/i18n/locales/ja/dashboard.json new file mode 100644 index 0000000000..455f45e24e --- /dev/null +++ b/webview-ui/src/i18n/locales/ja/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "ダッシュボード", + "done": "戻る", + "range": { + "today": "今日", + "7d": "過去7日間", + "30d": "過去30日間", + "custom": "カスタム", + "all": "全期間" + }, + "summary": { + "totalTokens": "合計トークン", + "inputTokens": "入力トークン", + "outputTokens": "出力トークン", + "cacheTokens": "キャッシュトークン", + "cost": "コスト" + }, + "states": { + "loading": "読み込んでいます...", + "error": "統計の読み込みに失敗しました", + "empty": "まだ使用量データがありません", + "emptyHint": "統計を表示するには会話を開始してください" + }, + "actions": { + "refresh": "更新", + "exportJson": "JSONエクスポート", + "exportCsv": "CSVエクスポート", + "clear": "統計を削除" + }, + "breakdown": { + "title": "内訳", + "model": "モデル", + "provider": "プロバイダー", + "mode": "モード", + "events": "イベント", + "inputTokens": "入力", + "outputTokens": "出力", + "cacheReadTokens": "キャッシュ読み取り", + "cacheWriteTokens": "キャッシュ書き込み", + "reasoningTokens": "推論", + "totalTokens": "合計", + "costUsd": "コスト", + "unknown": "不明" + }, + "coverage": { + "title": "データカバレッジ", + "liveFrom": "記録開始", + "lastUpdated": "最終更新", + "backfilledEvents": "遡及されたイベント", + "paused": "記録は一時停止されています (ストレージ上限に達しました)" + }, + "clearDialog": { + "title": "統計を削除", + "description": "すべての使用量統計を削除してもよろしいですか?この操作は元に戻せません。", + "cancel": "キャンセル", + "confirm": "削除" + }, + "customRange": { + "from": "開始", + "to": "終了" + }, + "sessions": { + "title": "セッション", + "noSessions": "この期間にはセッションがありません", + "filterModel": "すべてのモデル", + "filterProvider": "すべてのプロバイダー", + "callCount": "{{count}} 回の呼び出し" + }, + "sessionDetail": { + "summary": "セッションサマリー", + "apiCalls": "API呼び出し", + "noApiCalls": "API呼び出しの記録がありません", + "input": "入力", + "output": "出力", + "cost": "コスト", + "model": "モデル", + "mode": "モード", + "time": "時刻", + "status": "ステータス" + }, + "time": { + "justNow": "たった今", + "minutesAgo": "{{count}}分前", + "hoursAgo": "{{count}}時間前", + "yesterday": "昨日", + "daysAgo": "{{count}}日前" + } +} diff --git a/webview-ui/src/i18n/locales/ja/stats.json b/webview-ui/src/i18n/locales/ja/stats.json new file mode 100644 index 0000000000..bbd8aaad22 --- /dev/null +++ b/webview-ui/src/i18n/locales/ja/stats.json @@ -0,0 +1,82 @@ +{ + "title": "使用量統計", + "done": "チャットに戻る", + "range": { + "today": "今日", + "7d": "過去7日間", + "30d": "過去30日間", + "all": "全期間" + }, + "summary": { + "totalTokens": "合計トークン", + "inputTokens": "入力トークン", + "outputTokens": "出力トークン", + "cacheTokens": "キャッシュトークン", + "costUsd": "合計コスト" + }, + "breakdown": { + "title": "内訳", + "groupBy": "グループ化", + "model": "モデル", + "provider": "プロバイダー", + "mode": "モード", + "status": "ステータス", + "day": "日", + "week": "週", + "month": "月", + "events": "イベント", + "completed": "完了", + "failed": "失敗", + "cancelled": "キャンセル", + "inputTokens": "入力", + "outputTokens": "出力", + "cacheReadTokens": "キャッシュ読み取り", + "cacheWriteTokens": "キャッシュ書き込み", + "reasoningTokens": "推論", + "totalTokens": "合計", + "costUsd": "コスト (USD)", + "unknown": "不明", + "empty": "この範囲の使用量データはありません。" + }, + "heatmap": { + "title": "日次アクティビティ", + "30d": "30日間", + "60d": "60日間", + "120d": "120日間", + "360d": "360日間", + "less": "少ない", + "more": "多い", + "noData": "データなし", + "loading": "読み込み中..." + }, + "coverage": { + "title": "データカバレッジ", + "liveFrom": "記録開始", + "backfilledEvents": "遡及されたイベント", + "paused": "記録は一時停止されています", + "notAvailable": "利用不可" + }, + "actions": { + "exportJson": "JSONエクスポート", + "exportCsv": "CSVエクスポート", + "clear": "すべてのデータを削除", + "refresh": "更新" + }, + "clearDialog": { + "title": "すべての使用量統計を削除しますか?", + "description": "ローカルストレージに記録されたすべての使用量イベントが完全に削除されます。この操作は元に戻せません。", + "confirm": "削除", + "cancel": "キャンセル" + }, + "states": { + "loading": "統計を読み込んでいます...", + "error": "統計の読み込みに失敗しました", + "empty": "まだ使用量データがありません。LLM API呼び出し後に統計がここに表示されます。", + "emptyHint": "AIアシスタントにメッセージを送信して使用量データの収集を開始してください。" + }, + "source": { + "provider": "プロバイダー報告", + "estimated": "推定", + "backfilled": "遡及" + } +} diff --git a/webview-ui/src/i18n/locales/ko/dashboard.json b/webview-ui/src/i18n/locales/ko/dashboard.json new file mode 100644 index 0000000000..22796783d8 --- /dev/null +++ b/webview-ui/src/i18n/locales/ko/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "대시보드", + "done": "뒤로", + "range": { + "today": "오늘", + "7d": "최근 7일", + "30d": "최근 30일", + "custom": "사용자 지정", + "all": "전체 기간" + }, + "summary": { + "totalTokens": "전체 토큰", + "inputTokens": "입력 토큰", + "outputTokens": "출력 토큰", + "cacheTokens": "캐시 토큰", + "cost": "비용" + }, + "states": { + "loading": "불러오는 중...", + "error": "통계를 불러오지 못했습니다", + "empty": "아직 사용량 데이터가 없습니다", + "emptyHint": "통계를 보려면 대화를 시작하세요" + }, + "actions": { + "refresh": "새로 고침", + "exportJson": "JSON 내보내기", + "exportCsv": "CSV 내보내기", + "clear": "통계 삭제" + }, + "breakdown": { + "title": "세부 내역", + "model": "모델", + "provider": "공급자", + "mode": "모드", + "events": "이벤트", + "inputTokens": "입력", + "outputTokens": "출력", + "cacheReadTokens": "캐시 읽기", + "cacheWriteTokens": "캐시 쓰기", + "reasoningTokens": "추론", + "totalTokens": "전체", + "costUsd": "비용", + "unknown": "알 수 없음" + }, + "coverage": { + "title": "데이터 범위", + "liveFrom": "기록 시작", + "lastUpdated": "마지막 업데이트", + "backfilledEvents": "소급된 이벤트", + "paused": "기록이 일시 중지됨 (저장소 한도에 도달함)" + }, + "clearDialog": { + "title": "통계 삭제", + "description": "모든 사용량 통계를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "cancel": "취소", + "confirm": "삭제" + }, + "customRange": { + "from": "시작", + "to": "종료" + }, + "sessions": { + "title": "세션", + "noSessions": "이 기간에는 세션이 없습니다", + "filterModel": "모든 모델", + "filterProvider": "모든 공급자", + "callCount": "{{count}}회 호출" + }, + "sessionDetail": { + "summary": "세션 요약", + "apiCalls": "API 호출", + "noApiCalls": "API 호출 기록이 없습니다", + "input": "입력", + "output": "출력", + "cost": "비용", + "model": "모델", + "mode": "모드", + "time": "시간", + "status": "상태" + }, + "time": { + "justNow": "방금", + "minutesAgo": "{{count}}분 전", + "hoursAgo": "{{count}}시간 전", + "yesterday": "어제", + "daysAgo": "{{count}}일 전" + } +} diff --git a/webview-ui/src/i18n/locales/ko/stats.json b/webview-ui/src/i18n/locales/ko/stats.json new file mode 100644 index 0000000000..96a28b1fd8 --- /dev/null +++ b/webview-ui/src/i18n/locales/ko/stats.json @@ -0,0 +1,82 @@ +{ + "title": "사용량 통계", + "done": "채팅으로 돌아가기", + "range": { + "today": "오늘", + "7d": "최근 7일", + "30d": "최근 30일", + "all": "전체 기간" + }, + "summary": { + "totalTokens": "전체 토큰", + "inputTokens": "입력 토큰", + "outputTokens": "출력 토큰", + "cacheTokens": "캐시 토큰", + "costUsd": "총 비용" + }, + "breakdown": { + "title": "세부 내역", + "groupBy": "그룹화 기준", + "model": "모델", + "provider": "공급자", + "mode": "모드", + "status": "상태", + "day": "일", + "week": "주", + "month": "월", + "events": "이벤트", + "completed": "완료됨", + "failed": "실패함", + "cancelled": "취소됨", + "inputTokens": "입력", + "outputTokens": "출력", + "cacheReadTokens": "캐시 읽기", + "cacheWriteTokens": "캐시 쓰기", + "reasoningTokens": "추론", + "totalTokens": "전체", + "costUsd": "비용 (USD)", + "unknown": "알 수 없음", + "empty": "이 범위에 대한 사용량 데이터가 없습니다." + }, + "heatmap": { + "title": "일일 활동", + "30d": "30일", + "60d": "60일", + "120d": "120일", + "360d": "360일", + "less": "적음", + "more": "많음", + "noData": "데이터 없음", + "loading": "불러오는 중..." + }, + "coverage": { + "title": "데이터 범위", + "liveFrom": "기록 시작", + "backfilledEvents": "소급된 이벤트", + "paused": "기록이 일시 중지됨", + "notAvailable": "사용할 수 없음" + }, + "actions": { + "exportJson": "JSON 내보내기", + "exportCsv": "CSV 내보내기", + "clear": "모든 데이터 삭제", + "refresh": "새로 고침" + }, + "clearDialog": { + "title": "모든 사용량 통계를 삭제하시겠습니까?", + "description": "로컬 저장소에 기록된 모든 사용량 이벤트가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.", + "confirm": "삭제", + "cancel": "취소" + }, + "states": { + "loading": "통계를 불러오는 중...", + "error": "통계를 불러오지 못했습니다", + "empty": "아직 사용량 데이터가 없습니다. LLM API 호출 후 통계가 여기에 표시됩니다.", + "emptyHint": "AI 어시스턴트에게 메시지를 보내 사용량 데이터 수집을 시작해 보세요." + }, + "source": { + "provider": "공급자 보고", + "estimated": "추정치", + "backfilled": "소급됨" + } +} diff --git a/webview-ui/src/i18n/locales/nl/dashboard.json b/webview-ui/src/i18n/locales/nl/dashboard.json new file mode 100644 index 0000000000..5b3f0dfd18 --- /dev/null +++ b/webview-ui/src/i18n/locales/nl/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "Dashboard", + "done": "Terug", + "range": { + "today": "Vandaag", + "7d": "7 dagen", + "30d": "30 dagen", + "custom": "Aangepast", + "all": "Alles" + }, + "summary": { + "totalTokens": "Totale tokens", + "inputTokens": "Invoer-tokens", + "outputTokens": "Uitvoer-tokens", + "cacheTokens": "Cache-tokens", + "cost": "Kosten" + }, + "states": { + "loading": "Laden...", + "error": "Statistieken laden mislukt", + "empty": "Nog geen gebruiksgegevens", + "emptyHint": "Start een gesprek om statistieken te zien" + }, + "actions": { + "refresh": "Vernieuwen", + "exportJson": "JSON exporteren", + "exportCsv": "CSV exporteren", + "clear": "Statistieken wissen" + }, + "breakdown": { + "title": "Uitsplitsing", + "model": "Model", + "provider": "Provider", + "mode": "Modus", + "events": "Gebeurtenissen", + "inputTokens": "Invoer", + "outputTokens": "Uitvoer", + "cacheReadTokens": "Cache-lees", + "cacheWriteTokens": "Cache-schrijf", + "reasoningTokens": "Redenering", + "totalTokens": "Totaal", + "costUsd": "Kosten", + "unknown": "Onbekend" + }, + "coverage": { + "title": "Dekking van gegevens", + "liveFrom": "Live sinds", + "lastUpdated": "Laatst bijgewerkt", + "backfilledEvents": "Aangevulde gebeurtenissen", + "paused": "Opname gepauzeerd (opslaglimiet bereikt)" + }, + "clearDialog": { + "title": "Statistieken wissen", + "description": "Weet u zeker dat u alle gebruiksstatistieken wilt wissen? Deze actie kan niet ongedaan worden gemaakt.", + "cancel": "Annuleren", + "confirm": "Wissen" + }, + "customRange": { + "from": "Van", + "to": "Tot" + }, + "sessions": { + "title": "Sessies", + "noSessions": "Geen sessies in dit tijdsbereik", + "filterModel": "Alle modellen", + "filterProvider": "Alle providers", + "callCount": "{{count}} aanroepen" + }, + "sessionDetail": { + "summary": "Sessieoverzicht", + "apiCalls": "API-aanroepen", + "noApiCalls": "Geen API-aanroepen geregistreerd", + "input": "Invoer", + "output": "Uitvoer", + "cost": "Kosten", + "model": "Model", + "mode": "Modus", + "time": "Tijd", + "status": "Status" + }, + "time": { + "justNow": "zojuist", + "minutesAgo": "{{count}} min geleden", + "hoursAgo": "{{count}} uur geleden", + "yesterday": "gisteren", + "daysAgo": "{{count}} dagen geleden" + } +} diff --git a/webview-ui/src/i18n/locales/nl/stats.json b/webview-ui/src/i18n/locales/nl/stats.json new file mode 100644 index 0000000000..6063f1ad33 --- /dev/null +++ b/webview-ui/src/i18n/locales/nl/stats.json @@ -0,0 +1,82 @@ +{ + "title": "Gebruiksstatistieken", + "done": "Terug naar chat", + "range": { + "today": "Vandaag", + "7d": "Laatste 7 dagen", + "30d": "Laatste 30 dagen", + "all": "Volledige periode" + }, + "summary": { + "totalTokens": "Totale tokens", + "inputTokens": "Invoer-tokens", + "outputTokens": "Uitvoer-tokens", + "cacheTokens": "Cache-tokens", + "costUsd": "Totale kosten" + }, + "breakdown": { + "title": "Uitsplitsing", + "groupBy": "Groeperen op", + "model": "Model", + "provider": "Provider", + "mode": "Modus", + "status": "Status", + "day": "Dag", + "week": "Week", + "month": "Maand", + "events": "Gebeurtenissen", + "completed": "Voltooid", + "failed": "Mislukt", + "cancelled": "Geannuleerd", + "inputTokens": "Invoer", + "outputTokens": "Uitvoer", + "cacheReadTokens": "Cache-lezen", + "cacheWriteTokens": "Cache-schrijven", + "reasoningTokens": "Redenering", + "totalTokens": "Totaal", + "costUsd": "Kosten (USD)", + "unknown": "Onbekend", + "empty": "Geen gebruiksgegevens voor dit bereik." + }, + "heatmap": { + "title": "Dagelijkse activiteit", + "30d": "30 dagen", + "60d": "60 dagen", + "120d": "120 dagen", + "360d": "360 dagen", + "less": "Minder", + "more": "Meer", + "noData": "Geen gegevens", + "loading": "Laden..." + }, + "coverage": { + "title": "Gegevensdekking", + "liveFrom": "Opname sinds", + "backfilledEvents": "Achteraf ingevoerde gebeurtenissen", + "paused": "Opname is gepauzeerd", + "notAvailable": "Niet beschikbaar" + }, + "actions": { + "exportJson": "JSON exporteren", + "exportCsv": "CSV exporteren", + "clear": "Alle gegevens wissen", + "refresh": "Vernieuwen" + }, + "clearDialog": { + "title": "Alle gebruiksstatistieken wissen?", + "description": "Hiermee worden alle geregistreerde gebruiksgebeurtenissen permanent verwijderd uit de lokale opslag. Deze actie kan niet ongedaan worden gemaakt.", + "confirm": "Verwijderen", + "cancel": "Annuleren" + }, + "states": { + "loading": "Statistieken laden...", + "error": "Statistieken laden mislukt", + "empty": "Nog geen gebruiksgegevens. Statistieken verschijnen hier nadat u LLM API-aanroepen hebt gedaan.", + "emptyHint": "Stuur een bericht naar uw AI-assistent om te beginnen met het verzamelen van gebruiksgegevens." + }, + "source": { + "provider": "Door provider gerapporteerd", + "estimated": "Geschat", + "backfilled": "Achteraf ingevoerd" + } +} diff --git a/webview-ui/src/i18n/locales/pl/dashboard.json b/webview-ui/src/i18n/locales/pl/dashboard.json new file mode 100644 index 0000000000..c739d4d2be --- /dev/null +++ b/webview-ui/src/i18n/locales/pl/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "Pulpit", + "done": "Wstecz", + "range": { + "today": "Dziś", + "7d": "7 dni", + "30d": "30 dni", + "custom": "Niestandardowy", + "all": "Wszystko" + }, + "summary": { + "totalTokens": "Tokeny łącznie", + "inputTokens": "Tokeny wejściowe", + "outputTokens": "Tokeny wyjściowe", + "cacheTokens": "Tokeny pamięci podręcznej", + "cost": "Koszt" + }, + "states": { + "loading": "Ładowanie...", + "error": "Nie udało się załadować statystyk", + "empty": "Brak danych użycia", + "emptyHint": "Rozpocznij rozmowę, aby zobaczyć statystyki" + }, + "actions": { + "refresh": "Odśwież", + "exportJson": "Eksportuj JSON", + "exportCsv": "Eksportuj CSV", + "clear": "Wyczyść statystyki" + }, + "breakdown": { + "title": "Podział", + "model": "Model", + "provider": "Dostawca", + "mode": "Tryb", + "events": "Zdarzenia", + "inputTokens": "Wejście", + "outputTokens": "Wyjście", + "cacheReadTokens": "Odczyt pamięci", + "cacheWriteTokens": "Zapis pamięci", + "reasoningTokens": "Wnioskowanie", + "totalTokens": "Łącznie", + "costUsd": "Koszt", + "unknown": "Nieznany" + }, + "coverage": { + "title": "Zakres danych", + "liveFrom": "Na żywo od", + "lastUpdated": "Ostatnia aktualizacja", + "backfilledEvents": "Zdarzenia uzupełnione wstecz", + "paused": "Nagrywanie wstrzymane (osiągnięto limit pamięci)" + }, + "clearDialog": { + "title": "Wyczyść statystyki", + "description": "Czy na pewno chcesz wyczyścić wszystkie statystyki użycia? Tej operacji nie można cofnąć.", + "cancel": "Anuluj", + "confirm": "Wyczyść" + }, + "customRange": { + "from": "Od", + "to": "Do" + }, + "sessions": { + "title": "Sesje", + "noSessions": "Brak sesji w tym zakresie czasu", + "filterModel": "Wszystkie modele", + "filterProvider": "Wszyscy dostawcy", + "callCount": "{{count}} wywołań" + }, + "sessionDetail": { + "summary": "Podsumowanie sesji", + "apiCalls": "Wywołania API", + "noApiCalls": "Brak zarejestrowanych wywołań API", + "input": "Wejście", + "output": "Wyjście", + "cost": "Koszt", + "model": "Model", + "mode": "Tryb", + "time": "Czas", + "status": "Status" + }, + "time": { + "justNow": "przed chwilą", + "minutesAgo": "{{count}} min temu", + "hoursAgo": "{{count}} godz. temu", + "yesterday": "wczoraj", + "daysAgo": "{{count}} dni temu" + } +} diff --git a/webview-ui/src/i18n/locales/pl/stats.json b/webview-ui/src/i18n/locales/pl/stats.json new file mode 100644 index 0000000000..0073ed6b9b --- /dev/null +++ b/webview-ui/src/i18n/locales/pl/stats.json @@ -0,0 +1,82 @@ +{ + "title": "Statystyki użycia", + "done": "Wróć do czatu", + "range": { + "today": "Dziś", + "7d": "Ostatnie 7 dni", + "30d": "Ostatnie 30 dni", + "all": "Cały okres" + }, + "summary": { + "totalTokens": "Tokeny łącznie", + "inputTokens": "Tokeny wejściowe", + "outputTokens": "Tokeny wyjściowe", + "cacheTokens": "Tokeny pamięci podręcznej", + "costUsd": "Całkowity koszt" + }, + "breakdown": { + "title": "Podział", + "groupBy": "Grupuj według", + "model": "Model", + "provider": "Dostawca", + "mode": "Tryb", + "status": "Status", + "day": "Dzień", + "week": "Tydzień", + "month": "Miesiąc", + "events": "Zdarzenia", + "completed": "Zakończone", + "failed": "Nieudane", + "cancelled": "Anulowane", + "inputTokens": "Wejście", + "outputTokens": "Wyjście", + "cacheReadTokens": "Odczyt pamięci podręcznej", + "cacheWriteTokens": "Zapis pamięci podręcznej", + "reasoningTokens": "Wnioskowanie", + "totalTokens": "Łącznie", + "costUsd": "Koszt (USD)", + "unknown": "Nieznane", + "empty": "Brak danych użycia dla tego zakresu." + }, + "heatmap": { + "title": "Codzienna aktywność", + "30d": "30 dni", + "60d": "60 dni", + "120d": "120 dni", + "360d": "360 dni", + "less": "Mniej", + "more": "Więcej", + "noData": "Brak danych", + "loading": "Ładowanie..." + }, + "coverage": { + "title": "Pokrycie danych", + "liveFrom": "Nagrywanie od", + "backfilledEvents": "Zdarzenia uzupełnione wstecz", + "paused": "Nagrywanie jest wstrzymane", + "notAvailable": "Niedostępne" + }, + "actions": { + "exportJson": "Eksportuj JSON", + "exportCsv": "Eksportuj CSV", + "clear": "Wyczyść wszystkie dane", + "refresh": "Odśwież" + }, + "clearDialog": { + "title": "Wyczyścić wszystkie statystyki użycia?", + "description": "Spowoduje to trwałe usunięcie wszystkich zarejestrowanych zdarzeń użycia z pamięci lokalnej. Tej akcji nie można cofnąć.", + "confirm": "Usuń", + "cancel": "Anuluj" + }, + "states": { + "loading": "Ładowanie statystyk...", + "error": "Nie udało się załadować statystyk", + "empty": "Brak danych użycia. Statystyki pojawią się tutaj po wykonaniu wywołań API LLM.", + "emptyHint": "Wyślij wiadomość do swojego asystenta AI, aby rozpocząć zbieranie danych użycia." + }, + "source": { + "provider": "Zgłoszone przez dostawcę", + "estimated": "Szacowane", + "backfilled": "Uzupełnione wstecz" + } +} diff --git a/webview-ui/src/i18n/locales/pt-BR/dashboard.json b/webview-ui/src/i18n/locales/pt-BR/dashboard.json new file mode 100644 index 0000000000..52034e9590 --- /dev/null +++ b/webview-ui/src/i18n/locales/pt-BR/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "Painel", + "done": "Voltar", + "range": { + "today": "Hoje", + "7d": "7 dias", + "30d": "30 dias", + "custom": "Personalizado", + "all": "Tudo" + }, + "summary": { + "totalTokens": "Tokens totais", + "inputTokens": "Tokens de entrada", + "outputTokens": "Tokens de saída", + "cacheTokens": "Tokens de cache", + "cost": "Custo" + }, + "states": { + "loading": "Carregando...", + "error": "Falha ao carregar as estatísticas", + "empty": "Ainda não há dados de uso", + "emptyHint": "Inicie uma conversa para ver as estatísticas" + }, + "actions": { + "refresh": "Atualizar", + "exportJson": "Exportar JSON", + "exportCsv": "Exportar CSV", + "clear": "Limpar estatísticas" + }, + "breakdown": { + "title": "Detalhamento", + "model": "Modelo", + "provider": "Provedor", + "mode": "Modo", + "events": "Eventos", + "inputTokens": "Entrada", + "outputTokens": "Saída", + "cacheReadTokens": "Leitura cache", + "cacheWriteTokens": "Escrita cache", + "reasoningTokens": "Raciocínio", + "totalTokens": "Total", + "costUsd": "Custo", + "unknown": "Desconhecido" + }, + "coverage": { + "title": "Cobertura de dados", + "liveFrom": "Ao vivo desde", + "lastUpdated": "Última atualização", + "backfilledEvents": "Eventos retroativos", + "paused": "Gravação pausada (limite de armazenamento atingido)" + }, + "clearDialog": { + "title": "Limpar estatísticas", + "description": "Tem certeza de que deseja limpar todas as estatísticas de uso? Esta ação não pode ser desfeita.", + "cancel": "Cancelar", + "confirm": "Limpar" + }, + "customRange": { + "from": "De", + "to": "Até" + }, + "sessions": { + "title": "Sessões", + "noSessions": "Nenhuma sessão neste período", + "filterModel": "Todos os modelos", + "filterProvider": "Todos os provedores", + "callCount": "{{count}} chamadas" + }, + "sessionDetail": { + "summary": "Resumo da sessão", + "apiCalls": "Chamadas de API", + "noApiCalls": "Nenhuma chamada de API registrada", + "input": "Entrada", + "output": "Saída", + "cost": "Custo", + "model": "Modelo", + "mode": "Modo", + "time": "Hora", + "status": "Status" + }, + "time": { + "justNow": "agora mesmo", + "minutesAgo": "há {{count}} min", + "hoursAgo": "há {{count}} h", + "yesterday": "ontem", + "daysAgo": "há {{count}} dias" + } +} diff --git a/webview-ui/src/i18n/locales/pt-BR/stats.json b/webview-ui/src/i18n/locales/pt-BR/stats.json new file mode 100644 index 0000000000..c0c0e8ad40 --- /dev/null +++ b/webview-ui/src/i18n/locales/pt-BR/stats.json @@ -0,0 +1,82 @@ +{ + "title": "Estatísticas de uso", + "done": "Voltar ao chat", + "range": { + "today": "Hoje", + "7d": "Últimos 7 dias", + "30d": "Últimos 30 dias", + "all": "Todo o período" + }, + "summary": { + "totalTokens": "Total de tokens", + "inputTokens": "Tokens de entrada", + "outputTokens": "Tokens de saída", + "cacheTokens": "Tokens de cache", + "costUsd": "Custo total" + }, + "breakdown": { + "title": "Detalhamento", + "groupBy": "Agrupar por", + "model": "Modelo", + "provider": "Provedor", + "mode": "Modo", + "status": "Status", + "day": "Dia", + "week": "Semana", + "month": "Mês", + "events": "Eventos", + "completed": "Concluídos", + "failed": "Falhados", + "cancelled": "Cancelados", + "inputTokens": "Entrada", + "outputTokens": "Saída", + "cacheReadTokens": "Leitura de cache", + "cacheWriteTokens": "Escrita de cache", + "reasoningTokens": "Raciocínio", + "totalTokens": "Total", + "costUsd": "Custo (USD)", + "unknown": "Desconhecido", + "empty": "Sem dados de uso para este intervalo." + }, + "heatmap": { + "title": "Atividade diária", + "30d": "30 dias", + "60d": "60 dias", + "120d": "120 dias", + "360d": "360 dias", + "less": "Menos", + "more": "Mais", + "noData": "Sem dados", + "loading": "Carregando..." + }, + "coverage": { + "title": "Cobertura de dados", + "liveFrom": "Gravando desde", + "backfilledEvents": "Eventos retroativos", + "paused": "A gravação está pausada", + "notAvailable": "Indisponível" + }, + "actions": { + "exportJson": "Exportar JSON", + "exportCsv": "Exportar CSV", + "clear": "Limpar todos os dados", + "refresh": "Atualizar" + }, + "clearDialog": { + "title": "Limpar todas as estatísticas de uso?", + "description": "Isso excluirá permanentemente todos os eventos de uso registrados do armazenamento local. Esta ação não pode ser desfeita.", + "confirm": "Excluir", + "cancel": "Cancelar" + }, + "states": { + "loading": "Carregando estatísticas...", + "error": "Falha ao carregar estatísticas", + "empty": "Ainda não há dados de uso. As estatísticas aparecerão aqui após você fazer chamadas de API LLM.", + "emptyHint": "Tente enviar uma mensagem ao seu assistente de IA para começar a coletar dados de uso." + }, + "source": { + "provider": "Reportado pelo provedor", + "estimated": "Estimado", + "backfilled": "Retroativo" + } +} diff --git a/webview-ui/src/i18n/locales/ru/dashboard.json b/webview-ui/src/i18n/locales/ru/dashboard.json new file mode 100644 index 0000000000..b1556a7b75 --- /dev/null +++ b/webview-ui/src/i18n/locales/ru/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "Панель", + "done": "Назад", + "range": { + "today": "Сегодня", + "7d": "7 дней", + "30d": "30 дней", + "custom": "Другой", + "all": "Всё" + }, + "summary": { + "totalTokens": "Всего токенов", + "inputTokens": "Входные токены", + "outputTokens": "Выходные токены", + "cacheTokens": "Токены кэша", + "cost": "Стоимость" + }, + "states": { + "loading": "Загрузка...", + "error": "Не удалось загрузить статистику", + "empty": "Данные об использовании пока отсутствуют", + "emptyHint": "Начните диалог, чтобы увидеть статистику" + }, + "actions": { + "refresh": "Обновить", + "exportJson": "Экспорт JSON", + "exportCsv": "Экспорт CSV", + "clear": "Очистить статистику" + }, + "breakdown": { + "title": "Детализация", + "model": "Модель", + "provider": "Поставщик", + "mode": "Режим", + "events": "События", + "inputTokens": "Вход", + "outputTokens": "Выход", + "cacheReadTokens": "Чтение кэша", + "cacheWriteTokens": "Запись кэша", + "reasoningTokens": "Рассуждение", + "totalTokens": "Всего", + "costUsd": "Стоимость", + "unknown": "Неизвестно" + }, + "coverage": { + "title": "Покрытие данных", + "liveFrom": "Запись с", + "lastUpdated": "Последнее обновление", + "backfilledEvents": "Ретроспективные события", + "paused": "Запись приостановлена (достигнут лимит хранилища)" + }, + "clearDialog": { + "title": "Очистить статистику", + "description": "Вы уверены, что хотите очистить всю статистику использования? Это действие нельзя отменить.", + "cancel": "Отмена", + "confirm": "Очистить" + }, + "customRange": { + "from": "С", + "to": "По" + }, + "sessions": { + "title": "Сессии", + "noSessions": "В этом периоде нет сессий", + "filterModel": "Все модели", + "filterProvider": "Все поставщики", + "callCount": "{{count}} вызовов" + }, + "sessionDetail": { + "summary": "Сводка по сессии", + "apiCalls": "Вызовы API", + "noApiCalls": "Зарегистрированных вызовов API нет", + "input": "Вход", + "output": "Выход", + "cost": "Стоимость", + "model": "Модель", + "mode": "Режим", + "time": "Время", + "status": "Статус" + }, + "time": { + "justNow": "только что", + "minutesAgo": "{{count}} мин назад", + "hoursAgo": "{{count}} ч назад", + "yesterday": "вчера", + "daysAgo": "{{count}} дн. назад" + } +} diff --git a/webview-ui/src/i18n/locales/ru/stats.json b/webview-ui/src/i18n/locales/ru/stats.json new file mode 100644 index 0000000000..af64a2c05f --- /dev/null +++ b/webview-ui/src/i18n/locales/ru/stats.json @@ -0,0 +1,82 @@ +{ + "title": "Статистика использования", + "done": "Вернуться в чат", + "range": { + "today": "Сегодня", + "7d": "Последние 7 дней", + "30d": "Последние 30 дней", + "all": "За всё время" + }, + "summary": { + "totalTokens": "Всего токенов", + "inputTokens": "Входные токены", + "outputTokens": "Выходные токены", + "cacheTokens": "Токены кэша", + "costUsd": "Общая стоимость" + }, + "breakdown": { + "title": "Детализация", + "groupBy": "Группировать по", + "model": "Модель", + "provider": "Поставщик", + "mode": "Режим", + "status": "Статус", + "day": "День", + "week": "Неделя", + "month": "Месяц", + "events": "События", + "completed": "Завершено", + "failed": "Ошибка", + "cancelled": "Отменено", + "inputTokens": "Вход", + "outputTokens": "Выход", + "cacheReadTokens": "Чтение кэша", + "cacheWriteTokens": "Запись кэша", + "reasoningTokens": "Рассуждение", + "totalTokens": "Всего", + "costUsd": "Стоимость (USD)", + "unknown": "Неизвестно", + "empty": "Нет данных об использовании для этого диапазона." + }, + "heatmap": { + "title": "Ежедневная активность", + "30d": "30 дней", + "60d": "60 дней", + "120d": "120 дней", + "360d": "360 дней", + "less": "Меньше", + "more": "Больше", + "noData": "Нет данных", + "loading": "Загрузка..." + }, + "coverage": { + "title": "Покрытие данных", + "liveFrom": "Запись с", + "backfilledEvents": "Ретроспективные события", + "paused": "Запись приостановлена", + "notAvailable": "Недоступно" + }, + "actions": { + "exportJson": "Экспорт JSON", + "exportCsv": "Экспорт CSV", + "clear": "Очистить все данные", + "refresh": "Обновить" + }, + "clearDialog": { + "title": "Очистить всю статистику использования?", + "description": "Это навсегда удалит все записанные события использования из локального хранилища. Это действие нельзя отменить.", + "confirm": "Удалить", + "cancel": "Отмена" + }, + "states": { + "loading": "Загрузка статистики...", + "error": "Не удалось загрузить статистику", + "empty": "Пока нет данных об использовании. Статистика появится здесь после вызовов API LLM.", + "emptyHint": "Отправьте сообщение вашему ИИ-ассистенту, чтобы начать сбор данных об использовании." + }, + "source": { + "provider": "Сообщено поставщиком", + "estimated": "Оценка", + "backfilled": "Ретроспективно" + } +} diff --git a/webview-ui/src/i18n/locales/tr/dashboard.json b/webview-ui/src/i18n/locales/tr/dashboard.json new file mode 100644 index 0000000000..1634307775 --- /dev/null +++ b/webview-ui/src/i18n/locales/tr/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "Panel", + "done": "Geri", + "range": { + "today": "Bugün", + "7d": "7 Gün", + "30d": "30 Gün", + "custom": "Özel", + "all": "Tümü" + }, + "summary": { + "totalTokens": "Toplam Token", + "inputTokens": "Giriş Token'ları", + "outputTokens": "Çıkış Token'ları", + "cacheTokens": "Önbellek Token'ları", + "cost": "Maliyet" + }, + "states": { + "loading": "Yükleniyor...", + "error": "İstatistikler yüklenemedi", + "empty": "Henüz kullanım verisi yok", + "emptyHint": "İstatistikleri görmek için bir konuşma başlatın" + }, + "actions": { + "refresh": "Yenile", + "exportJson": "JSON Dışa Aktar", + "exportCsv": "CSV Dışa Aktar", + "clear": "İstatistikleri Temizle" + }, + "breakdown": { + "title": "Döküm", + "model": "Model", + "provider": "Sağlayıcı", + "mode": "Mod", + "events": "Olaylar", + "inputTokens": "Giriş", + "outputTokens": "Çıkış", + "cacheReadTokens": "Önbellek Okuma", + "cacheWriteTokens": "Önbellek Yazma", + "reasoningTokens": "Çıkarım", + "totalTokens": "Toplam", + "costUsd": "Maliyet", + "unknown": "Bilinmiyor" + }, + "coverage": { + "title": "Veri Kapsamı", + "liveFrom": "Şu tarihten beri canlı", + "lastUpdated": "Son güncelleme", + "backfilledEvents": "Geriye dönük doldurulan olaylar", + "paused": "Kayıt duraklatıldı (depolama sınırına ulaşıldı)" + }, + "clearDialog": { + "title": "İstatistikleri Temizle", + "description": "Tüm kullanım istatistiklerini temizlemek istediğinizden emin misiniz? Bu işlem geri alınamaz.", + "cancel": "İptal", + "confirm": "Temizle" + }, + "customRange": { + "from": "Başlangıç", + "to": "Bitiş" + }, + "sessions": { + "title": "Oturumlar", + "noSessions": "Bu zaman aralığında oturum yok", + "filterModel": "Tüm Modeller", + "filterProvider": "Tüm Sağlayıcılar", + "callCount": "{{count}} çağrı" + }, + "sessionDetail": { + "summary": "Oturum Özeti", + "apiCalls": "API Çağrıları", + "noApiCalls": "Kayıtlı API çağrısı yok", + "input": "Giriş", + "output": "Çıkış", + "cost": "Maliyet", + "model": "Model", + "mode": "Mod", + "time": "Zaman", + "status": "Durum" + }, + "time": { + "justNow": "az önce", + "minutesAgo": "{{count}} dk önce", + "hoursAgo": "{{count}} saat önce", + "yesterday": "dün", + "daysAgo": "{{count}} gün önce" + } +} diff --git a/webview-ui/src/i18n/locales/tr/stats.json b/webview-ui/src/i18n/locales/tr/stats.json new file mode 100644 index 0000000000..1c1e705f14 --- /dev/null +++ b/webview-ui/src/i18n/locales/tr/stats.json @@ -0,0 +1,82 @@ +{ + "title": "Kullanım istatistikleri", + "done": "Sohbete dön", + "range": { + "today": "Bugün", + "7d": "Son 7 gün", + "30d": "Son 30 gün", + "all": "Tüm zamanlar" + }, + "summary": { + "totalTokens": "Toplam token", + "inputTokens": "Giriş token'ları", + "outputTokens": "Çıkış token'ları", + "cacheTokens": "Önbellek token'ları", + "costUsd": "Toplam maliyet" + }, + "breakdown": { + "title": "Döküm", + "groupBy": "Gruplandırma ölçütü", + "model": "Model", + "provider": "Sağlayıcı", + "mode": "Mod", + "status": "Durum", + "day": "Gün", + "week": "Hafta", + "month": "Ay", + "events": "Olaylar", + "completed": "Tamamlandı", + "failed": "Başarısız", + "cancelled": "İptal edildi", + "inputTokens": "Giriş", + "outputTokens": "Çıkış", + "cacheReadTokens": "Önbellek okuma", + "cacheWriteTokens": "Önbellek yazma", + "reasoningTokens": "Akıl yürütme", + "totalTokens": "Toplam", + "costUsd": "Maliyet (USD)", + "unknown": "Bilinmiyor", + "empty": "Bu aralık için kullanım verisi yok." + }, + "heatmap": { + "title": "Günlük aktivite", + "30d": "30 gün", + "60d": "60 gün", + "120d": "120 gün", + "360d": "360 gün", + "less": "Az", + "more": "Çok", + "noData": "Veri yok", + "loading": "Yükleniyor..." + }, + "coverage": { + "title": "Veri kapsamı", + "liveFrom": "Kayıt başlangıcı", + "backfilledEvents": "Geriye dönük olaylar", + "paused": "Kayıt duraklatıldı", + "notAvailable": "Kullanılamıyor" + }, + "actions": { + "exportJson": "JSON dışa aktar", + "exportCsv": "CSV dışa aktar", + "clear": "Tüm verileri sil", + "refresh": "Yenile" + }, + "clearDialog": { + "title": "Tüm kullanım istatistikleri silinsin mi?", + "description": "Bu, yerel depolamadaki tüm kayıtlı kullanım olaylarını kalıcı olarak siler. Bu işlem geri alınamaz.", + "confirm": "Sil", + "cancel": "İptal" + }, + "states": { + "loading": "İstatistikler yükleniyor...", + "error": "İstatistikler yüklenemedi", + "empty": "Henüz kullanım verisi yok. LLM API çağrıları yaptıktan sonra istatistikler burada görünecek.", + "emptyHint": "Kullanım verisi toplamaya başlamak için yapay zeka asistanınıza bir mesaj göndermeyi deneyin." + }, + "source": { + "provider": "Sağlayıcı tarafından bildirildi", + "estimated": "Tahmini", + "backfilled": "Geriye dönük" + } +} diff --git a/webview-ui/src/i18n/locales/vi/dashboard.json b/webview-ui/src/i18n/locales/vi/dashboard.json new file mode 100644 index 0000000000..ddd6b09ce2 --- /dev/null +++ b/webview-ui/src/i18n/locales/vi/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "Bảng điều khiển", + "done": "Quay lại", + "range": { + "today": "Hôm nay", + "7d": "7 ngày", + "30d": "30 ngày", + "custom": "Tùy chỉnh", + "all": "Tất cả" + }, + "summary": { + "totalTokens": "Tổng token", + "inputTokens": "Token đầu vào", + "outputTokens": "Token đầu ra", + "cacheTokens": "Token bộ nhớ đệm", + "cost": "Chi phí" + }, + "states": { + "loading": "Đang tải...", + "error": "Không thể tải thống kê", + "empty": "Chưa có dữ liệu sử dụng", + "emptyHint": "Bắt đầu cuộc trò chuyện để xem thống kê" + }, + "actions": { + "refresh": "Làm mới", + "exportJson": "Xuất JSON", + "exportCsv": "Xuất CSV", + "clear": "Xóa thống kê" + }, + "breakdown": { + "title": "Phân tích", + "model": "Mô hình", + "provider": "Nhà cung cấp", + "mode": "Chế độ", + "events": "Sự kiện", + "inputTokens": "Đầu vào", + "outputTokens": "Đầu ra", + "cacheReadTokens": "Đọc bộ nhớ đệm", + "cacheWriteTokens": "Ghi bộ nhớ đệm", + "reasoningTokens": "Suy luận", + "totalTokens": "Tổng", + "costUsd": "Chi phí", + "unknown": "Không xác định" + }, + "coverage": { + "title": "Phạm vi dữ liệu", + "liveFrom": "Trực tiếp từ", + "lastUpdated": "Cập nhật lần cuối", + "backfilledEvents": "Sự kiện bổ sung hồi tố", + "paused": "Đã tạm dừng ghi (đã đạt giới hạn lưu trữ)" + }, + "clearDialog": { + "title": "Xóa thống kê", + "description": "Bạn có chắc chắn muốn xóa tất cả thống kê sử dụng không? Hành động này không thể hoàn tác.", + "cancel": "Hủy", + "confirm": "Xóa" + }, + "customRange": { + "from": "Từ", + "to": "Đến" + }, + "sessions": { + "title": "Phiên", + "noSessions": "Không có phiên trong khoảng thời gian này", + "filterModel": "Tất cả mô hình", + "filterProvider": "Tất cả nhà cung cấp", + "callCount": "{{count}} lượt gọi" + }, + "sessionDetail": { + "summary": "Tóm tắt phiên", + "apiCalls": "Lời gọi API", + "noApiCalls": "Không có lời gọi API nào được ghi lại", + "input": "Đầu vào", + "output": "Đầu ra", + "cost": "Chi phí", + "model": "Mô hình", + "mode": "Chế độ", + "time": "Thời gian", + "status": "Trạng thái" + }, + "time": { + "justNow": "vua xong", + "minutesAgo": "{{count}} phut truoc", + "hoursAgo": "{{count}} gio truoc", + "yesterday": "hom qua", + "daysAgo": "{{count}} ngay truoc" + } +} diff --git a/webview-ui/src/i18n/locales/vi/stats.json b/webview-ui/src/i18n/locales/vi/stats.json new file mode 100644 index 0000000000..d2275e7a69 --- /dev/null +++ b/webview-ui/src/i18n/locales/vi/stats.json @@ -0,0 +1,82 @@ +{ + "title": "Thống kê sử dụng", + "done": "Quay lại trò chuyện", + "range": { + "today": "Hôm nay", + "7d": "7 ngày qua", + "30d": "30 ngày qua", + "all": "Tất cả thời gian" + }, + "summary": { + "totalTokens": "Tổng số token", + "inputTokens": "Token đầu vào", + "outputTokens": "Token đầu ra", + "cacheTokens": "Token bộ nhớ đệm", + "costUsd": "Tổng chi phí" + }, + "breakdown": { + "title": "Phân tích chi tiết", + "groupBy": "Nhóm theo", + "model": "Mô hình", + "provider": "Nhà cung cấp", + "mode": "Chế độ", + "status": "Trạng thái", + "day": "Ngày", + "week": "Tuần", + "month": "Tháng", + "events": "Sự kiện", + "completed": "Hoàn thành", + "failed": "Thất bại", + "cancelled": "Đã hủy", + "inputTokens": "Đầu vào", + "outputTokens": "Đầu ra", + "cacheReadTokens": "Đọc bộ nhớ đệm", + "cacheWriteTokens": "Ghi bộ nhớ đệm", + "reasoningTokens": "Suy luận", + "totalTokens": "Tổng", + "costUsd": "Chi phí (USD)", + "unknown": "Không xác định", + "empty": "Không có dữ liệu sử dụng cho phạm vi này." + }, + "heatmap": { + "title": "Hoạt động hàng ngày", + "30d": "30 ngày", + "60d": "60 ngày", + "120d": "120 ngày", + "360d": "360 ngày", + "less": "Ít hơn", + "more": "Nhiều hơn", + "noData": "Không có dữ liệu", + "loading": "Đang tải..." + }, + "coverage": { + "title": "Phạm vi dữ liệu", + "liveFrom": "Bắt đầu ghi từ", + "backfilledEvents": "Sự kiện được bổ sung hồi tố", + "paused": "Ghi đang tạm dừng", + "notAvailable": "Không khả dụng" + }, + "actions": { + "exportJson": "Xuất JSON", + "exportCsv": "Xuất CSV", + "clear": "Xóa tất cả dữ liệu", + "refresh": "Làm mới" + }, + "clearDialog": { + "title": "Xóa tất cả thống kê sử dụng?", + "description": "Thao tác này sẽ xóa vĩnh viễn tất cả sự kiện sử dụng đã ghi khỏi bộ nhớ cục bộ. Hành động này không thể hoàn tác.", + "confirm": "Xóa", + "cancel": "Hủy" + }, + "states": { + "loading": "Đang tải thống kê...", + "error": "Không thể tải thống kê", + "empty": "Chưa có dữ liệu sử dụng. Thống kê sẽ xuất hiện ở đây sau khi bạn thực hiện các lệnh gọi API LLM.", + "emptyHint": "Hãy thử gửi tin nhắn cho trợ lý AI của bạn để bắt đầu thu thập dữ liệu sử dụng." + }, + "source": { + "provider": "Được báo cáo bởi nhà cung cấp", + "estimated": "Ước tính", + "backfilled": "Được bổ sung hồi tố" + } +} diff --git a/webview-ui/src/i18n/locales/zh-CN/dashboard.json b/webview-ui/src/i18n/locales/zh-CN/dashboard.json new file mode 100644 index 0000000000..2ec719b735 --- /dev/null +++ b/webview-ui/src/i18n/locales/zh-CN/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "仪表盘", + "done": "返回", + "range": { + "today": "今天", + "7d": "7 天", + "30d": "30 天", + "custom": "自定义", + "all": "全部" + }, + "summary": { + "totalTokens": "总 Token", + "inputTokens": "输入 Token", + "outputTokens": "输出 Token", + "cacheTokens": "缓存 Token", + "cost": "费用" + }, + "states": { + "loading": "加载中...", + "error": "加载统计数据失败", + "empty": "暂无使用数据", + "emptyHint": "开始对话即可查看统计数据" + }, + "actions": { + "refresh": "刷新", + "exportJson": "导出 JSON", + "exportCsv": "导出 CSV", + "clear": "清除统计" + }, + "breakdown": { + "title": "明细", + "model": "模型", + "provider": "提供商", + "mode": "模式", + "events": "事件", + "inputTokens": "输入", + "outputTokens": "输出", + "cacheReadTokens": "缓存读取", + "cacheWriteTokens": "缓存写入", + "reasoningTokens": "推理", + "totalTokens": "总计", + "costUsd": "费用", + "unknown": "未知" + }, + "coverage": { + "title": "数据覆盖范围", + "liveFrom": "实时记录自", + "lastUpdated": "最后更新", + "backfilledEvents": "回填事件", + "paused": "记录已暂停(已达到存储上限)" + }, + "clearDialog": { + "title": "清除统计", + "description": "确定要清除所有使用统计吗?此操作无法撤销。", + "cancel": "取消", + "confirm": "清除" + }, + "customRange": { + "from": "从", + "to": "到" + }, + "sessions": { + "title": "会话", + "noSessions": "此时间范围内没有会话", + "filterModel": "所有模型", + "filterProvider": "所有提供商", + "callCount": "{{count}} 次调用" + }, + "sessionDetail": { + "summary": "会话摘要", + "apiCalls": "API 调用", + "noApiCalls": "没有记录的 API 调用", + "input": "输入", + "output": "输出", + "cost": "费用", + "model": "模型", + "mode": "模式", + "time": "时间", + "status": "状态" + }, + "time": { + "justNow": "刚刚", + "minutesAgo": "{{count}}分钟前", + "hoursAgo": "{{count}}小时前", + "yesterday": "昨天", + "daysAgo": "{{count}}天前" + } +} diff --git a/webview-ui/src/i18n/locales/zh-CN/stats.json b/webview-ui/src/i18n/locales/zh-CN/stats.json new file mode 100644 index 0000000000..0cfee65fa1 --- /dev/null +++ b/webview-ui/src/i18n/locales/zh-CN/stats.json @@ -0,0 +1,82 @@ +{ + "title": "使用量统计", + "done": "返回聊天", + "range": { + "today": "今天", + "7d": "最近7天", + "30d": "最近30天", + "all": "全部时间" + }, + "summary": { + "totalTokens": "总令牌数", + "inputTokens": "输入令牌", + "outputTokens": "输出令牌", + "cacheTokens": "缓存令牌", + "costUsd": "总费用" + }, + "breakdown": { + "title": "明细", + "groupBy": "分组依据", + "model": "模型", + "provider": "提供商", + "mode": "模式", + "status": "状态", + "day": "日", + "week": "周", + "month": "月", + "events": "事件", + "completed": "已完成", + "failed": "失败", + "cancelled": "已取消", + "inputTokens": "输入", + "outputTokens": "输出", + "cacheReadTokens": "缓存读取", + "cacheWriteTokens": "缓存写入", + "reasoningTokens": "推理", + "totalTokens": "总计", + "costUsd": "费用 (USD)", + "unknown": "未知", + "empty": "此范围内没有使用量数据。" + }, + "heatmap": { + "title": "每日活动", + "30d": "30天", + "60d": "60天", + "120d": "120天", + "360d": "360天", + "less": "较少", + "more": "较多", + "noData": "无数据", + "loading": "加载中..." + }, + "coverage": { + "title": "数据覆盖范围", + "liveFrom": "记录开始", + "backfilledEvents": "回填事件", + "paused": "记录已暂停", + "notAvailable": "不可用" + }, + "actions": { + "exportJson": "导出 JSON", + "exportCsv": "导出 CSV", + "clear": "清除所有数据", + "refresh": "刷新" + }, + "clearDialog": { + "title": "清除所有使用量统计?", + "description": "这将永久删除本地存储中记录的所有使用量事件。此操作无法撤销。", + "confirm": "删除", + "cancel": "取消" + }, + "states": { + "loading": "正在加载统计信息...", + "error": "加载统计信息失败", + "empty": "暂无使用量数据。LLM API 调用后统计信息将显示在此处。", + "emptyHint": "尝试向您的 AI 助手发送消息以开始收集使用量数据。" + }, + "source": { + "provider": "提供商报告", + "estimated": "估算", + "backfilled": "回填" + } +} diff --git a/webview-ui/src/i18n/locales/zh-TW/dashboard.json b/webview-ui/src/i18n/locales/zh-TW/dashboard.json new file mode 100644 index 0000000000..bc3cc86b1a --- /dev/null +++ b/webview-ui/src/i18n/locales/zh-TW/dashboard.json @@ -0,0 +1,88 @@ +{ + "title": "儀表板", + "done": "返回", + "range": { + "today": "今天", + "7d": "7 天", + "30d": "30 天", + "custom": "自訂", + "all": "全部" + }, + "summary": { + "totalTokens": "總 Token", + "inputTokens": "輸入 Token", + "outputTokens": "輸出 Token", + "cacheTokens": "快取 Token", + "cost": "費用" + }, + "states": { + "loading": "載入中...", + "error": "載入統計資料失敗", + "empty": "尚無使用資料", + "emptyHint": "開始對話即可查看統計資料" + }, + "actions": { + "refresh": "重新整理", + "exportJson": "匯出 JSON", + "exportCsv": "匯出 CSV", + "clear": "清除統計" + }, + "breakdown": { + "title": "明細", + "model": "模型", + "provider": "供應商", + "mode": "模式", + "events": "事件", + "inputTokens": "輸入", + "outputTokens": "輸出", + "cacheReadTokens": "快取讀取", + "cacheWriteTokens": "快取寫入", + "reasoningTokens": "推理", + "totalTokens": "總計", + "costUsd": "費用", + "unknown": "未知" + }, + "coverage": { + "title": "資料涵蓋範圍", + "liveFrom": "即時記錄自", + "lastUpdated": "最後更新", + "backfilledEvents": "回填事件", + "paused": "記錄已暫停(已達儲存上限)" + }, + "clearDialog": { + "title": "清除統計", + "description": "確定要清除所有使用統計嗎?此動作無法復原。", + "cancel": "取消", + "confirm": "清除" + }, + "customRange": { + "from": "從", + "to": "到" + }, + "sessions": { + "title": "工作階段", + "noSessions": "此時間範圍內沒有工作階段", + "filterModel": "所有模型", + "filterProvider": "所有供應商", + "callCount": "{{count}} 次呼叫" + }, + "sessionDetail": { + "summary": "工作階段摘要", + "apiCalls": "API 呼叫", + "noApiCalls": "沒有記錄的 API 呼叫", + "input": "輸入", + "output": "輸出", + "cost": "費用", + "model": "模型", + "mode": "模式", + "time": "時間", + "status": "狀態" + }, + "time": { + "justNow": "剛剛", + "minutesAgo": "{{count}}分鐘前", + "hoursAgo": "{{count}}小時前", + "yesterday": "昨天", + "daysAgo": "{{count}}天前" + } +} diff --git a/webview-ui/src/i18n/locales/zh-TW/stats.json b/webview-ui/src/i18n/locales/zh-TW/stats.json new file mode 100644 index 0000000000..ea85b12d88 --- /dev/null +++ b/webview-ui/src/i18n/locales/zh-TW/stats.json @@ -0,0 +1,82 @@ +{ + "title": "使用量統計", + "done": "返回聊天", + "range": { + "today": "今天", + "7d": "最近7天", + "30d": "最近30天", + "all": "全部時間" + }, + "summary": { + "totalTokens": "總詞元數", + "inputTokens": "輸入詞元", + "outputTokens": "輸出詞元", + "cacheTokens": "快取詞元", + "costUsd": "總費用" + }, + "breakdown": { + "title": "明細", + "groupBy": "分組依據", + "model": "模型", + "provider": "供應商", + "mode": "模式", + "status": "狀態", + "day": "日", + "week": "週", + "month": "月", + "events": "事件", + "completed": "已完成", + "failed": "失敗", + "cancelled": "已取消", + "inputTokens": "輸入", + "outputTokens": "輸出", + "cacheReadTokens": "快取讀取", + "cacheWriteTokens": "快取寫入", + "reasoningTokens": "推理", + "totalTokens": "總計", + "costUsd": "費用 (USD)", + "unknown": "未知", + "empty": "此範圍內沒有使用量資料。" + }, + "heatmap": { + "title": "每日活動", + "30d": "30天", + "60d": "60天", + "120d": "120天", + "360d": "360天", + "less": "較少", + "more": "較多", + "noData": "無資料", + "loading": "載入中..." + }, + "coverage": { + "title": "資料涵蓋範圍", + "liveFrom": "記錄開始", + "backfilledEvents": "回填事件", + "paused": "記錄已暫停", + "notAvailable": "無法使用" + }, + "actions": { + "exportJson": "匯出 JSON", + "exportCsv": "匯出 CSV", + "clear": "清除所有資料", + "refresh": "重新整理" + }, + "clearDialog": { + "title": "清除所有使用量統計?", + "description": "這將永久刪除本機儲存中記錄的所有使用量事件。此操作無法復原。", + "confirm": "刪除", + "cancel": "取消" + }, + "states": { + "loading": "正在載入統計資料...", + "error": "載入統計資料失敗", + "empty": "尚無使用量資料。LLM API 呼叫後統計資料將顯示在此處。", + "emptyHint": "嘗試向您的 AI 助理傳送訊息以開始收集使用量資料。" + }, + "source": { + "provider": "供應商回報", + "estimated": "估算", + "backfilled": "回填" + } +} diff --git a/webview-ui/src/utils/__tests__/formatNumber.spec.ts b/webview-ui/src/utils/__tests__/formatNumber.spec.ts new file mode 100644 index 0000000000..42f2a42c43 --- /dev/null +++ b/webview-ui/src/utils/__tests__/formatNumber.spec.ts @@ -0,0 +1,150 @@ +import { formatCompact, formatCost } from "../formatNumber" + +describe("formatCompact", () => { + it("returns '0' for zero", () => { + expect(formatCompact(0)).toBe("0") + }) + + it("returns '0' for negative zero", () => { + expect(formatCompact(-0)).toBe("0") + }) + + it("formats numbers below 1,000 with toLocaleString", () => { + expect(formatCompact(999)).toBe("999") + }) + + it("formats exactly 1,000 with K suffix", () => { + expect(formatCompact(1_000)).toBe("1.0K") + }) + + it("formats 1,500 with K suffix and one decimal", () => { + expect(formatCompact(1_500)).toBe("1.5K") + }) + + it("formats exactly 1,000,000 with M suffix", () => { + expect(formatCompact(1_000_000)).toBe("1.00M") + }) + + it("formats 1,500,000 with M suffix and two decimals", () => { + expect(formatCompact(1_500_000)).toBe("1.50M") + }) + + it("formats exactly 1,000,000,000 with B suffix", () => { + expect(formatCompact(1_000_000_000)).toBe("1.00B") + }) + + it("formats 1,500,000,000 with B suffix and two decimals", () => { + expect(formatCompact(1_500_000_000)).toBe("1.50B") + }) + + it("formats negative numbers with K suffix", () => { + expect(formatCompact(-1_500)).toBe("-1.5K") + }) + + it("formats negative numbers with M suffix", () => { + expect(formatCompact(-1_500_000)).toBe("-1.50M") + }) + + it("formats negative numbers with B suffix", () => { + expect(formatCompact(-1_500_000_000)).toBe("-1.50B") + }) + + it("formats negative numbers below 1,000 with toLocaleString", () => { + expect(formatCompact(-42)).toBe("-42") + }) + + it("formats very large numbers with B suffix", () => { + expect(formatCompact(999_999_999_999)).toBe("1000.00B") + }) + + it("formats boundary just below 1,000", () => { + expect(formatCompact(999)).toBe("999") + }) + + it("formats boundary at exactly 1,000", () => { + expect(formatCompact(1_000)).toBe("1.0K") + }) + + it("formats boundary just below 1,000,000", () => { + expect(formatCompact(999_999)).toBe("1000.0K") + }) + + it("formats boundary at exactly 1,000,000", () => { + expect(formatCompact(1_000_000)).toBe("1.00M") + }) + + it("formats boundary just below 1,000,000,000", () => { + expect(formatCompact(999_999_999)).toBe("1000.00M") + }) + + it("formats boundary at exactly 1,000,000,000", () => { + expect(formatCompact(1_000_000_000)).toBe("1.00B") + }) + + it("handles NaN by returning 'NaN' via toLocaleString", () => { + // NaN is not 0, abs(NaN) is NaN, which is < 1000, so toLocaleString is called + expect(formatCompact(NaN)).toBe("NaN") + }) + + it("handles Infinity with B suffix", () => { + expect(formatCompact(Infinity)).toBe("InfinityB") + }) + + it("handles -Infinity with B suffix", () => { + expect(formatCompact(-Infinity)).toBe("-InfinityB") + }) +}) + +describe("formatCost", () => { + it("returns '$0.00' for zero", () => { + expect(formatCost(0)).toBe("$0.00") + }) + + it("returns '$0.00' for negative zero", () => { + expect(formatCost(-0)).toBe("$0.00") + }) + + it("formats sub-cent values with 4 decimals", () => { + expect(formatCost(0.005)).toBe("$0.0050") + }) + + it("formats values just below 0.01 with 4 decimals", () => { + expect(formatCost(0.0099)).toBe("$0.0099") + }) + + it("formats exactly 0.01 with 2 decimals", () => { + expect(formatCost(0.01)).toBe("$0.01") + }) + + it("formats typical cost with 2 decimals", () => { + expect(formatCost(1.234)).toBe("$1.23") + }) + + it("formats large cost with 2 decimals", () => { + expect(formatCost(1234.567)).toBe("$1234.57") + }) + + it("formats negative sub-cent values with 4 decimals", () => { + expect(formatCost(-0.005)).toBe("$-0.0050") + }) + + it("formats negative values with 4 decimals (negative is always < 0.01)", () => { + expect(formatCost(-1.5)).toBe("$-1.5000") + }) + + it("formats very small positive value", () => { + expect(formatCost(0.0001)).toBe("$0.0001") + }) + + it("formats very large cost", () => { + expect(formatCost(999999.99)).toBe("$999999.99") + }) + + it("handles NaN (falls through to toFixed(2))", () => { + expect(formatCost(NaN)).toBe("$NaN") + }) + + it("handles Infinity (falls through to toFixed(2))", () => { + expect(formatCost(Infinity)).toBe("$Infinity") + }) +}) diff --git a/webview-ui/src/utils/formatNumber.ts b/webview-ui/src/utils/formatNumber.ts new file mode 100644 index 0000000000..33e5d6566e --- /dev/null +++ b/webview-ui/src/utils/formatNumber.ts @@ -0,0 +1,43 @@ +/** + * Shared number/cost formatting helpers for the dashboard views. + * + * These were previously duplicated across DashboardSummary, DashboardView, + * SessionList, and SessionDetail. Centralizing them here ensures consistent + * formatting (K/M/B suffixes, currency precision) across all dashboard + * surfaces and makes future adjustments a single-file change. + */ + +/** + * Format a large number with K/M/B suffixes for display. + * + * - 0 -> "0" + * - 999 -> "999" + * - 1_500 -> "1.5K" + * - 1_500_000 -> "1.50M" + * - 1_500_000_000 -> "1.50B" + * + * The exact unrounded value is intended to be surfaced via a tooltip + * `title` attribute by callers, so this function only returns the + * compact representation. + */ +export function formatCompact(value: number): string { + if (value === 0) return "0" + const abs = Math.abs(value) + if (abs >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B` + if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M` + if (abs >= 1_000) return `${(value / 1_000).toFixed(1)}K` + return value.toLocaleString() +} + +/** + * Format a USD cost value for display. + * + * - 0 -> "$0.00" + * - 0.005 -> "$0.0050" (sub-cent values keep 4 decimals for visibility) + * - 1.234 -> "$1.23" + */ +export function formatCost(value: number): string { + if (value === 0) return "$0.00" + if (value < 0.01) return `$${value.toFixed(4)}` + return `$${value.toFixed(2)}` +}