diff --git a/src/gateway/server-methods/usage.test.ts b/src/gateway/server-methods/usage.test.ts index 3a64a224c6d3..6ae9f307e983 100644 --- a/src/gateway/server-methods/usage.test.ts +++ b/src/gateway/server-methods/usage.test.ts @@ -37,6 +37,39 @@ import { testApi, usageHandlers } from "./usage.js"; describe("gateway usage helpers", () => { const dayMs = 24 * 60 * 60 * 1000; + const costSummary = (params: { date?: string; totalTokens: number; totalCost: number }) => ({ + updatedAt: Date.now(), + days: 1, + daily: [ + { + date: params.date ?? "2026-02-01", + input: params.totalTokens, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: params.totalTokens, + totalCost: params.totalCost, + inputCost: params.totalCost, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, + missingCostEntries: 0, + }, + ], + totals: { + input: params.totalTokens, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: params.totalTokens, + totalCost: params.totalCost, + inputCost: params.totalCost, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, + missingCostEntries: 0, + }, + }); function expectUtcDateRange( range: ReturnType, @@ -257,4 +290,64 @@ describe("gateway usage helpers", () => { expect.objectContaining({ agentId: "research" }), ); }); + + it("aggregates usage.cost only for explicit all-agent scope", async () => { + vi.mocked(loadCostUsageSummaryFromCache).mockImplementation(async (params) => + params?.agentId === "opus" + ? costSummary({ totalTokens: 20, totalCost: 2 }) + : costSummary({ totalTokens: 10, totalCost: 1 }), + ); + + const config = { + agents: { list: [{ id: "main" }, { id: "opus" }] }, + session: {}, + } as OpenClawConfig; + const context = { getRuntimeConfig: () => config }; + const params = { startDate: "2026-02-01", endDate: "2026-02-01", mode: "utc" }; + + const defaultRespond = vi.fn(); + await usageHandlers["usage.cost"]({ + respond: defaultRespond, + params, + context, + } as unknown as Parameters<(typeof usageHandlers)["usage.cost"]>[0]); + + expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledTimes(1); + expect(vi.mocked(loadCostUsageSummaryFromCache).mock.calls[0]?.[0]?.agentId).toBeUndefined(); + expect(defaultRespond.mock.calls[0]?.[1]).toMatchObject({ + totals: { totalTokens: 10, totalCost: 1 }, + }); + + const aggregateRespond = vi.fn(); + await usageHandlers["usage.cost"]({ + respond: aggregateRespond, + params: { ...params, agentScope: "all" }, + context, + } as unknown as Parameters<(typeof usageHandlers)["usage.cost"]>[0]); + + expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledTimes(3); + expect( + vi.mocked(loadCostUsageSummaryFromCache) + .mock.calls.slice(1) + .map((call) => call[0]?.agentId), + ).toEqual(["main", "opus"]); + expect(aggregateRespond.mock.calls[0]?.[0]).toBe(true); + expect(aggregateRespond.mock.calls[0]?.[1]).toMatchObject({ + totals: { totalTokens: 30, totalCost: 3 }, + daily: [{ date: "2026-02-01", totalTokens: 30, totalCost: 3 }], + }); + + const mainRespond = vi.fn(); + await usageHandlers["usage.cost"]({ + respond: mainRespond, + params: { ...params, agentId: "main" }, + context, + } as unknown as Parameters<(typeof usageHandlers)["usage.cost"]>[0]); + + expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledTimes(4); + expect(vi.mocked(loadCostUsageSummaryFromCache).mock.calls[3]?.[0]?.agentId).toBe("main"); + expect(mainRespond.mock.calls[0]?.[1]).toMatchObject({ + totals: { totalTokens: 10, totalCost: 1 }, + }); + }); }); diff --git a/ui/src/ui/views/usage-metrics.node.test.ts b/ui/src/ui/views/usage-metrics.node.test.ts new file mode 100644 index 000000000000..e5bd06a604aa --- /dev/null +++ b/ui/src/ui/views/usage-metrics.node.test.ts @@ -0,0 +1,23 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { withEnvAsync } from "../../../../src/test-utils/env.js"; +import { formatDayLabel, formatFullDate } from "./usage-metrics.ts"; + +describe("usage metrics date labels", () => { + it("formats YYYY-MM-DD values as stable calendar dates in negative UTC offsets", async () => { + await withEnvAsync({ TZ: "America/Los_Angeles" }, async () => { + const date = new Date(2026, 1, 1); + expect(formatDayLabel("2026-02-01")).toBe( + date.toLocaleDateString(undefined, { month: "short", day: "numeric" }), + ); + expect(formatFullDate("2026-02-01")).toBe( + date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" }), + ); + }); + }); + + it("leaves invalid day labels unchanged", () => { + expect(formatDayLabel("2026-02-31")).toBe("2026-02-31"); + expect(formatFullDate("2026-02-31")).toBe("2026-02-31"); + }); +}); diff --git a/ui/src/ui/views/usage-metrics.ts b/ui/src/ui/views/usage-metrics.ts index eb077d6adde2..6618f046796a 100644 --- a/ui/src/ui/views/usage-metrics.ts +++ b/ui/src/ui/views/usage-metrics.ts @@ -452,8 +452,19 @@ function parseYmdDate(dateStr: string): Date | null { return null; } const [, y, m, d] = match; - const date = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d))); - return Number.isNaN(date.valueOf()) ? null : date; + const year = Number(y); + const monthIndex = Number(m) - 1; + const day = Number(d); + const date = new Date(year, monthIndex, day); + if ( + Number.isNaN(date.valueOf()) || + date.getFullYear() !== year || + date.getMonth() !== monthIndex || + date.getDate() !== day + ) { + return null; + } + return date; } function formatDayLabel(dateStr: string): string {