Web UI: add token usage dashboard (#10072)
* feat(ui): Token Usage dashboard with session analytics Adds a comprehensive Token Usage view to the dashboard: Backend: - Extended session-cost-usage.ts with per-session daily breakdown - Added date range filtering (startMs/endMs) to API endpoints - New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints - Cost breakdown by token type (input/output/cache read/write) Frontend: - Two-column layout: Daily chart + breakdown | Sessions list - Interactive daily bar chart with click-to-filter and shift-click range select - Session detail panel with usage timeline, conversation logs, context weight - Filter chips for active day/session selections - Toggle between tokens/cost view modes (default: cost) - Responsive design for smaller screens UX improvements: - 21-day default date range - Debounced date input (400ms) - Session list shows filtered totals when days selected - Context weight breakdown shows skills, tools, files contribution * fix(ui): restore gatewayUrl validation and syncUrlWithSessionKey signature - Restore normalizeGatewayUrl() to validate ws:/wss: protocol - Restore isTopLevelWindow() guard for iframe security - Revert syncUrlWithSessionKey signature (host param was unused) * feat(ui): Token Usage dashboard with session analytics Adds a comprehensive Token Usage view to the dashboard: Backend: - Extended session-cost-usage.ts with per-session daily breakdown - Added date range filtering (startMs/endMs) to API endpoints - New sessions.usage, sessions.usage.timeseries, sessions.usage.logs endpoints - Cost breakdown by token type (input/output/cache read/write) Frontend: - Two-column layout: Daily chart + breakdown | Sessions list - Interactive daily bar chart with click-to-filter and shift-click range select - Session detail panel with usage timeline, conversation logs, context weight - Filter chips for active day/session selections - Toggle between tokens/cost view modes (default: cost) - Responsive design for smaller screens UX improvements: - 21-day default date range - Debounced date input (400ms) - Session list shows filtered totals when days selected - Context weight breakdown shows skills, tools, files contribution * fix: usage dashboard data + cost handling (#8462) (thanks @mcinteerj) * Usage: enrich metrics dashboard * Usage: add latency + model trends * Gateway: improve usage log parsing * UI: add usage query helpers * UI: client-side usage filter + debounce * Build: harden write-cli-compat timing * UI: add conversation log filters * UI: fix usage dashboard lint + state * Web UI: default usage dates to local day * Protocol: sync session usage params (#8462) (thanks @mcinteerj, @TakHoffman) --------- Co-authored-by: Jake McInteer <mcinteerj@gmail.com>
This commit is contained in:
@@ -161,6 +161,8 @@ import {
|
||||
SessionsResetParamsSchema,
|
||||
type SessionsResolveParams,
|
||||
SessionsResolveParamsSchema,
|
||||
type SessionsUsageParams,
|
||||
SessionsUsageParamsSchema,
|
||||
type ShutdownEvent,
|
||||
ShutdownEventSchema,
|
||||
type SkillsBinsParams,
|
||||
@@ -271,6 +273,8 @@ export const validateSessionsDeleteParams = ajv.compile<SessionsDeleteParams>(
|
||||
export const validateSessionsCompactParams = ajv.compile<SessionsCompactParams>(
|
||||
SessionsCompactParamsSchema,
|
||||
);
|
||||
export const validateSessionsUsageParams =
|
||||
ajv.compile<SessionsUsageParams>(SessionsUsageParamsSchema);
|
||||
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(ConfigGetParamsSchema);
|
||||
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(ConfigSetParamsSchema);
|
||||
export const validateConfigApplyParams = ajv.compile<ConfigApplyParams>(ConfigApplyParamsSchema);
|
||||
@@ -412,6 +416,7 @@ export {
|
||||
SessionsResetParamsSchema,
|
||||
SessionsDeleteParamsSchema,
|
||||
SessionsCompactParamsSchema,
|
||||
SessionsUsageParamsSchema,
|
||||
ConfigGetParamsSchema,
|
||||
ConfigSetParamsSchema,
|
||||
ConfigApplyParamsSchema,
|
||||
@@ -541,6 +546,7 @@ export type {
|
||||
SessionsResetParams,
|
||||
SessionsDeleteParams,
|
||||
SessionsCompactParams,
|
||||
SessionsUsageParams,
|
||||
CronJob,
|
||||
CronListParams,
|
||||
CronStatusParams,
|
||||
|
||||
@@ -117,6 +117,7 @@ import {
|
||||
SessionsPreviewParamsSchema,
|
||||
SessionsResetParamsSchema,
|
||||
SessionsResolveParamsSchema,
|
||||
SessionsUsageParamsSchema,
|
||||
} from "./sessions.js";
|
||||
import { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
|
||||
import {
|
||||
@@ -168,6 +169,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
SessionsResetParams: SessionsResetParamsSchema,
|
||||
SessionsDeleteParams: SessionsDeleteParamsSchema,
|
||||
SessionsCompactParams: SessionsCompactParamsSchema,
|
||||
SessionsUsageParams: SessionsUsageParamsSchema,
|
||||
ConfigGetParams: ConfigGetParamsSchema,
|
||||
ConfigSetParams: ConfigSetParamsSchema,
|
||||
ConfigApplyParams: ConfigApplyParamsSchema,
|
||||
|
||||
@@ -101,3 +101,19 @@ export const SessionsCompactParamsSchema = Type.Object(
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsUsageParamsSchema = Type.Object(
|
||||
{
|
||||
/** Specific session key to analyze; if omitted returns all sessions. */
|
||||
key: Type.Optional(NonEmptyString),
|
||||
/** Start date for range filter (YYYY-MM-DD). */
|
||||
startDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
|
||||
/** End date for range filter (YYYY-MM-DD). */
|
||||
endDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
|
||||
/** Maximum sessions to return (default 50). */
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
/** Include context weight breakdown (systemPromptReport). */
|
||||
includeContextWeight: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -110,6 +110,7 @@ import type {
|
||||
SessionsPreviewParamsSchema,
|
||||
SessionsResetParamsSchema,
|
||||
SessionsResolveParamsSchema,
|
||||
SessionsUsageParamsSchema,
|
||||
} from "./sessions.js";
|
||||
import type { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
|
||||
import type {
|
||||
@@ -157,6 +158,7 @@ export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
||||
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
|
||||
export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
|
||||
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
|
||||
export type SessionsUsageParams = Static<typeof SessionsUsageParamsSchema>;
|
||||
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
||||
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
|
||||
export type ConfigApplyParams = Static<typeof ConfigApplyParamsSchema>;
|
||||
|
||||
82
src/gateway/server-methods/usage.test.ts
Normal file
82
src/gateway/server-methods/usage.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../infra/session-cost-usage.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../infra/session-cost-usage.js")>(
|
||||
"../../infra/session-cost-usage.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadCostUsageSummary: vi.fn(async () => ({
|
||||
updatedAt: Date.now(),
|
||||
startDate: "2026-02-01",
|
||||
endDate: "2026-02-02",
|
||||
daily: [],
|
||||
totals: { totalTokens: 1, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0 },
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
|
||||
import { __test } from "./usage.js";
|
||||
|
||||
describe("gateway usage helpers", () => {
|
||||
beforeEach(() => {
|
||||
__test.costUsageCache.clear();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("parseDateToMs accepts YYYY-MM-DD and rejects invalid input", () => {
|
||||
expect(__test.parseDateToMs("2026-02-05")).toBe(Date.UTC(2026, 1, 5));
|
||||
expect(__test.parseDateToMs(" 2026-02-05 ")).toBe(Date.UTC(2026, 1, 5));
|
||||
expect(__test.parseDateToMs("2026-2-5")).toBeUndefined();
|
||||
expect(__test.parseDateToMs("nope")).toBeUndefined();
|
||||
expect(__test.parseDateToMs(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parseDays coerces strings/numbers to integers", () => {
|
||||
expect(__test.parseDays(7.9)).toBe(7);
|
||||
expect(__test.parseDays("30")).toBe(30);
|
||||
expect(__test.parseDays("")).toBeUndefined();
|
||||
expect(__test.parseDays("nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parseDateRange uses explicit start/end (inclusive end of day)", () => {
|
||||
const range = __test.parseDateRange({ startDate: "2026-02-01", endDate: "2026-02-02" });
|
||||
expect(range.startMs).toBe(Date.UTC(2026, 1, 1));
|
||||
expect(range.endMs).toBe(Date.UTC(2026, 1, 2) + 24 * 60 * 60 * 1000 - 1);
|
||||
});
|
||||
|
||||
it("parseDateRange clamps days to at least 1 and defaults to 30 days", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-05T12:34:56.000Z"));
|
||||
const oneDay = __test.parseDateRange({ days: 0 });
|
||||
expect(oneDay.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1);
|
||||
expect(oneDay.startMs).toBe(Date.UTC(2026, 1, 5));
|
||||
|
||||
const def = __test.parseDateRange({});
|
||||
expect(def.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1);
|
||||
expect(def.startMs).toBe(Date.UTC(2026, 1, 5) - 29 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("loadCostUsageSummaryCached caches within TTL", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-05T00:00:00.000Z"));
|
||||
|
||||
const config = {} as unknown as ReturnType<import("../../config/config.js").loadConfig>;
|
||||
const a = await __test.loadCostUsageSummaryCached({
|
||||
startMs: 1,
|
||||
endMs: 2,
|
||||
config,
|
||||
});
|
||||
const b = await __test.loadCostUsageSummaryCached({
|
||||
startMs: 1,
|
||||
endMs: 2,
|
||||
config,
|
||||
});
|
||||
|
||||
expect(a.totals.totalTokens).toBe(1);
|
||||
expect(b.totals.totalTokens).toBe(1);
|
||||
expect(vi.mocked(loadCostUsageSummary)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,68 @@
|
||||
import type { CostUsageSummary } from "../../infra/session-cost-usage.js";
|
||||
import fs from "node:fs";
|
||||
import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js";
|
||||
import type {
|
||||
CostUsageSummary,
|
||||
SessionCostSummary,
|
||||
SessionDailyLatency,
|
||||
SessionDailyModelUsage,
|
||||
SessionMessageCounts,
|
||||
SessionLatencyStats,
|
||||
SessionModelUsage,
|
||||
SessionToolUsage,
|
||||
} from "../../infra/session-cost-usage.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { resolveSessionFilePath } from "../../config/sessions/paths.js";
|
||||
import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
|
||||
import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
|
||||
import {
|
||||
loadCostUsageSummary,
|
||||
loadSessionCostSummary,
|
||||
loadSessionUsageTimeSeries,
|
||||
discoverAllSessions,
|
||||
} from "../../infra/session-cost-usage.js";
|
||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateSessionsUsageParams,
|
||||
} from "../protocol/index.js";
|
||||
import { loadCombinedSessionStoreForGateway, loadSessionEntry } from "../session-utils.js";
|
||||
|
||||
const COST_USAGE_CACHE_TTL_MS = 30_000;
|
||||
|
||||
type DateRange = { startMs: number; endMs: number };
|
||||
|
||||
type CostUsageCacheEntry = {
|
||||
summary?: CostUsageSummary;
|
||||
updatedAt?: number;
|
||||
inFlight?: Promise<CostUsageSummary>;
|
||||
};
|
||||
|
||||
const costUsageCache = new Map<number, CostUsageCacheEntry>();
|
||||
const costUsageCache = new Map<string, CostUsageCacheEntry>();
|
||||
|
||||
const parseDays = (raw: unknown): number => {
|
||||
/**
|
||||
* Parse a date string (YYYY-MM-DD) to start of day timestamp in UTC.
|
||||
* Returns undefined if invalid.
|
||||
*/
|
||||
const parseDateToMs = (raw: unknown): number | undefined => {
|
||||
if (typeof raw !== "string" || !raw.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw.trim());
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const [, year, month, day] = match;
|
||||
// Use UTC to ensure consistent behavior across timezones
|
||||
const ms = Date.UTC(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
if (Number.isNaN(ms)) {
|
||||
return undefined;
|
||||
}
|
||||
return ms;
|
||||
};
|
||||
|
||||
const parseDays = (raw: unknown): number | undefined => {
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return Math.floor(raw);
|
||||
}
|
||||
@@ -24,16 +72,51 @@ const parseDays = (raw: unknown): number => {
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
}
|
||||
return 30;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get date range from params (startDate/endDate or days).
|
||||
* Falls back to last 30 days if not provided.
|
||||
*/
|
||||
const parseDateRange = (params: {
|
||||
startDate?: unknown;
|
||||
endDate?: unknown;
|
||||
days?: unknown;
|
||||
}): DateRange => {
|
||||
const now = new Date();
|
||||
// Use UTC for consistent date handling
|
||||
const todayStartMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const todayEndMs = todayStartMs + 24 * 60 * 60 * 1000 - 1;
|
||||
|
||||
const startMs = parseDateToMs(params.startDate);
|
||||
const endMs = parseDateToMs(params.endDate);
|
||||
|
||||
if (startMs !== undefined && endMs !== undefined) {
|
||||
// endMs should be end of day
|
||||
return { startMs, endMs: endMs + 24 * 60 * 60 * 1000 - 1 };
|
||||
}
|
||||
|
||||
const days = parseDays(params.days);
|
||||
if (days !== undefined) {
|
||||
const clampedDays = Math.max(1, days);
|
||||
const start = todayStartMs - (clampedDays - 1) * 24 * 60 * 60 * 1000;
|
||||
return { startMs: start, endMs: todayEndMs };
|
||||
}
|
||||
|
||||
// Default to last 30 days
|
||||
const defaultStartMs = todayStartMs - 29 * 24 * 60 * 60 * 1000;
|
||||
return { startMs: defaultStartMs, endMs: todayEndMs };
|
||||
};
|
||||
|
||||
async function loadCostUsageSummaryCached(params: {
|
||||
days: number;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
config: ReturnType<typeof loadConfig>;
|
||||
}): Promise<CostUsageSummary> {
|
||||
const days = Math.max(1, params.days);
|
||||
const cacheKey = `${params.startMs}-${params.endMs}`;
|
||||
const now = Date.now();
|
||||
const cached = costUsageCache.get(days);
|
||||
const cached = costUsageCache.get(cacheKey);
|
||||
if (cached?.summary && cached.updatedAt && now - cached.updatedAt < COST_USAGE_CACHE_TTL_MS) {
|
||||
return cached.summary;
|
||||
}
|
||||
@@ -46,9 +129,13 @@ async function loadCostUsageSummaryCached(params: {
|
||||
}
|
||||
|
||||
const entry: CostUsageCacheEntry = cached ?? {};
|
||||
const inFlight = loadCostUsageSummary({ days, config: params.config })
|
||||
const inFlight = loadCostUsageSummary({
|
||||
startMs: params.startMs,
|
||||
endMs: params.endMs,
|
||||
config: params.config,
|
||||
})
|
||||
.then((summary) => {
|
||||
costUsageCache.set(days, { summary, updatedAt: Date.now() });
|
||||
costUsageCache.set(cacheKey, { summary, updatedAt: Date.now() });
|
||||
return summary;
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -58,15 +145,15 @@ async function loadCostUsageSummaryCached(params: {
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
const current = costUsageCache.get(days);
|
||||
const current = costUsageCache.get(cacheKey);
|
||||
if (current?.inFlight === inFlight) {
|
||||
current.inFlight = undefined;
|
||||
costUsageCache.set(days, current);
|
||||
costUsageCache.set(cacheKey, current);
|
||||
}
|
||||
});
|
||||
|
||||
entry.inFlight = inFlight;
|
||||
costUsageCache.set(days, entry);
|
||||
costUsageCache.set(cacheKey, entry);
|
||||
|
||||
if (entry.summary) {
|
||||
return entry.summary;
|
||||
@@ -74,6 +161,70 @@ async function loadCostUsageSummaryCached(params: {
|
||||
return await inFlight;
|
||||
}
|
||||
|
||||
// Exposed for unit tests (kept as a single export to avoid widening the public API surface).
|
||||
export const __test = {
|
||||
parseDateToMs,
|
||||
parseDays,
|
||||
parseDateRange,
|
||||
loadCostUsageSummaryCached,
|
||||
costUsageCache,
|
||||
};
|
||||
|
||||
export type SessionUsageEntry = {
|
||||
key: string;
|
||||
label?: string;
|
||||
sessionId?: string;
|
||||
updatedAt?: number;
|
||||
agentId?: string;
|
||||
channel?: string;
|
||||
chatType?: string;
|
||||
origin?: {
|
||||
label?: string;
|
||||
provider?: string;
|
||||
surface?: string;
|
||||
chatType?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
modelOverride?: string;
|
||||
providerOverride?: string;
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
usage: SessionCostSummary | null;
|
||||
contextWeight?: SessionSystemPromptReport | null;
|
||||
};
|
||||
|
||||
export type SessionsUsageAggregates = {
|
||||
messages: SessionMessageCounts;
|
||||
tools: SessionToolUsage;
|
||||
byModel: SessionModelUsage[];
|
||||
byProvider: SessionModelUsage[];
|
||||
byAgent: Array<{ agentId: string; totals: CostUsageSummary["totals"] }>;
|
||||
byChannel: Array<{ channel: string; totals: CostUsageSummary["totals"] }>;
|
||||
latency?: SessionLatencyStats;
|
||||
dailyLatency?: SessionDailyLatency[];
|
||||
modelDaily?: SessionDailyModelUsage[];
|
||||
daily: Array<{
|
||||
date: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
messages: number;
|
||||
toolCalls: number;
|
||||
errors: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SessionsUsageResult = {
|
||||
updatedAt: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
sessions: SessionUsageEntry[];
|
||||
totals: CostUsageSummary["totals"];
|
||||
aggregates: SessionsUsageAggregates;
|
||||
};
|
||||
|
||||
export const usageHandlers: GatewayRequestHandlers = {
|
||||
"usage.status": async ({ respond }) => {
|
||||
const summary = await loadProviderUsageSummary();
|
||||
@@ -81,8 +232,535 @@ export const usageHandlers: GatewayRequestHandlers = {
|
||||
},
|
||||
"usage.cost": async ({ respond, params }) => {
|
||||
const config = loadConfig();
|
||||
const days = parseDays(params?.days);
|
||||
const summary = await loadCostUsageSummaryCached({ days, config });
|
||||
const { startMs, endMs } = parseDateRange({
|
||||
startDate: params?.startDate,
|
||||
endDate: params?.endDate,
|
||||
days: params?.days,
|
||||
});
|
||||
const summary = await loadCostUsageSummaryCached({ startMs, endMs, config });
|
||||
respond(true, summary, undefined);
|
||||
},
|
||||
"sessions.usage": async ({ respond, params }) => {
|
||||
if (!validateSessionsUsageParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid sessions.usage params: ${formatValidationErrors(validateSessionsUsageParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const p = params;
|
||||
const config = loadConfig();
|
||||
const { startMs, endMs } = parseDateRange({
|
||||
startDate: p.startDate,
|
||||
endDate: p.endDate,
|
||||
});
|
||||
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50;
|
||||
const includeContextWeight = p.includeContextWeight ?? false;
|
||||
const specificKey = typeof p.key === "string" ? p.key.trim() : null;
|
||||
|
||||
// Load session store for named sessions
|
||||
const { store } = loadCombinedSessionStoreForGateway(config);
|
||||
const now = Date.now();
|
||||
|
||||
// Merge discovered sessions with store entries
|
||||
type MergedEntry = {
|
||||
key: string;
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
label?: string;
|
||||
updatedAt: number;
|
||||
storeEntry?: SessionEntry;
|
||||
firstUserMessage?: string;
|
||||
};
|
||||
|
||||
const mergedEntries: MergedEntry[] = [];
|
||||
|
||||
// Optimization: If a specific key is requested, skip full directory scan
|
||||
if (specificKey) {
|
||||
// Check if it's a named session in the store
|
||||
const storeEntry = store[specificKey];
|
||||
let sessionId = storeEntry?.sessionId ?? specificKey;
|
||||
|
||||
// Resolve the session file path
|
||||
const sessionFile = resolveSessionFilePath(sessionId, storeEntry);
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(sessionFile);
|
||||
if (stats.isFile()) {
|
||||
mergedEntries.push({
|
||||
key: specificKey,
|
||||
sessionId,
|
||||
sessionFile,
|
||||
label: storeEntry?.label,
|
||||
updatedAt: storeEntry?.updatedAt ?? stats.mtimeMs,
|
||||
storeEntry,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist - no results for this key
|
||||
}
|
||||
} else {
|
||||
// Full discovery for list view
|
||||
const discoveredSessions = await discoverAllSessions({
|
||||
startMs,
|
||||
endMs,
|
||||
});
|
||||
|
||||
// Build a map of sessionId -> store entry for quick lookup
|
||||
const storeBySessionId = new Map<string, { key: string; entry: SessionEntry }>();
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
if (entry?.sessionId) {
|
||||
storeBySessionId.set(entry.sessionId, { key, entry });
|
||||
}
|
||||
}
|
||||
|
||||
for (const discovered of discoveredSessions) {
|
||||
const storeMatch = storeBySessionId.get(discovered.sessionId);
|
||||
if (storeMatch) {
|
||||
// Named session from store
|
||||
mergedEntries.push({
|
||||
key: storeMatch.key,
|
||||
sessionId: discovered.sessionId,
|
||||
sessionFile: discovered.sessionFile,
|
||||
label: storeMatch.entry.label,
|
||||
updatedAt: storeMatch.entry.updatedAt ?? discovered.mtime,
|
||||
storeEntry: storeMatch.entry,
|
||||
});
|
||||
} else {
|
||||
// Unnamed session - use session ID as key, no label
|
||||
mergedEntries.push({
|
||||
key: discovered.sessionId,
|
||||
sessionId: discovered.sessionId,
|
||||
sessionFile: discovered.sessionFile,
|
||||
label: undefined, // No label for unnamed sessions
|
||||
updatedAt: discovered.mtime,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by most recent first
|
||||
mergedEntries.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
// Apply limit
|
||||
const limitedEntries = mergedEntries.slice(0, limit);
|
||||
|
||||
// Load usage for each session
|
||||
const sessions: SessionUsageEntry[] = [];
|
||||
const aggregateTotals = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
inputCost: 0,
|
||||
outputCost: 0,
|
||||
cacheReadCost: 0,
|
||||
cacheWriteCost: 0,
|
||||
missingCostEntries: 0,
|
||||
};
|
||||
const aggregateMessages: SessionMessageCounts = {
|
||||
total: 0,
|
||||
user: 0,
|
||||
assistant: 0,
|
||||
toolCalls: 0,
|
||||
toolResults: 0,
|
||||
errors: 0,
|
||||
};
|
||||
const toolAggregateMap = new Map<string, number>();
|
||||
const byModelMap = new Map<string, SessionModelUsage>();
|
||||
const byProviderMap = new Map<string, SessionModelUsage>();
|
||||
const byAgentMap = new Map<string, CostUsageSummary["totals"]>();
|
||||
const byChannelMap = new Map<string, CostUsageSummary["totals"]>();
|
||||
const dailyAggregateMap = new Map<
|
||||
string,
|
||||
{
|
||||
date: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
messages: number;
|
||||
toolCalls: number;
|
||||
errors: number;
|
||||
}
|
||||
>();
|
||||
const latencyTotals = {
|
||||
count: 0,
|
||||
sum: 0,
|
||||
min: Number.POSITIVE_INFINITY,
|
||||
max: 0,
|
||||
p95Max: 0,
|
||||
};
|
||||
const dailyLatencyMap = new Map<
|
||||
string,
|
||||
{ date: string; count: number; sum: number; min: number; max: number; p95Max: number }
|
||||
>();
|
||||
const modelDailyMap = new Map<string, SessionDailyModelUsage>();
|
||||
|
||||
const emptyTotals = (): CostUsageSummary["totals"] => ({
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
inputCost: 0,
|
||||
outputCost: 0,
|
||||
cacheReadCost: 0,
|
||||
cacheWriteCost: 0,
|
||||
missingCostEntries: 0,
|
||||
});
|
||||
const mergeTotals = (
|
||||
target: CostUsageSummary["totals"],
|
||||
source: CostUsageSummary["totals"],
|
||||
) => {
|
||||
target.input += source.input;
|
||||
target.output += source.output;
|
||||
target.cacheRead += source.cacheRead;
|
||||
target.cacheWrite += source.cacheWrite;
|
||||
target.totalTokens += source.totalTokens;
|
||||
target.totalCost += source.totalCost;
|
||||
target.inputCost += source.inputCost;
|
||||
target.outputCost += source.outputCost;
|
||||
target.cacheReadCost += source.cacheReadCost;
|
||||
target.cacheWriteCost += source.cacheWriteCost;
|
||||
target.missingCostEntries += source.missingCostEntries;
|
||||
};
|
||||
|
||||
for (const merged of limitedEntries) {
|
||||
const usage = await loadSessionCostSummary({
|
||||
sessionId: merged.sessionId,
|
||||
sessionEntry: merged.storeEntry,
|
||||
sessionFile: merged.sessionFile,
|
||||
config,
|
||||
startMs,
|
||||
endMs,
|
||||
});
|
||||
|
||||
if (usage) {
|
||||
aggregateTotals.input += usage.input;
|
||||
aggregateTotals.output += usage.output;
|
||||
aggregateTotals.cacheRead += usage.cacheRead;
|
||||
aggregateTotals.cacheWrite += usage.cacheWrite;
|
||||
aggregateTotals.totalTokens += usage.totalTokens;
|
||||
aggregateTotals.totalCost += usage.totalCost;
|
||||
aggregateTotals.inputCost += usage.inputCost;
|
||||
aggregateTotals.outputCost += usage.outputCost;
|
||||
aggregateTotals.cacheReadCost += usage.cacheReadCost;
|
||||
aggregateTotals.cacheWriteCost += usage.cacheWriteCost;
|
||||
aggregateTotals.missingCostEntries += usage.missingCostEntries;
|
||||
}
|
||||
|
||||
const agentId = parseAgentSessionKey(merged.key)?.agentId;
|
||||
const channel = merged.storeEntry?.channel ?? merged.storeEntry?.origin?.provider;
|
||||
const chatType = merged.storeEntry?.chatType ?? merged.storeEntry?.origin?.chatType;
|
||||
|
||||
if (usage) {
|
||||
if (usage.messageCounts) {
|
||||
aggregateMessages.total += usage.messageCounts.total;
|
||||
aggregateMessages.user += usage.messageCounts.user;
|
||||
aggregateMessages.assistant += usage.messageCounts.assistant;
|
||||
aggregateMessages.toolCalls += usage.messageCounts.toolCalls;
|
||||
aggregateMessages.toolResults += usage.messageCounts.toolResults;
|
||||
aggregateMessages.errors += usage.messageCounts.errors;
|
||||
}
|
||||
|
||||
if (usage.toolUsage) {
|
||||
for (const tool of usage.toolUsage.tools) {
|
||||
toolAggregateMap.set(tool.name, (toolAggregateMap.get(tool.name) ?? 0) + tool.count);
|
||||
}
|
||||
}
|
||||
|
||||
if (usage.modelUsage) {
|
||||
for (const entry of usage.modelUsage) {
|
||||
const modelKey = `${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`;
|
||||
const modelExisting =
|
||||
byModelMap.get(modelKey) ??
|
||||
({
|
||||
provider: entry.provider,
|
||||
model: entry.model,
|
||||
count: 0,
|
||||
totals: emptyTotals(),
|
||||
} as SessionModelUsage);
|
||||
modelExisting.count += entry.count;
|
||||
mergeTotals(modelExisting.totals, entry.totals);
|
||||
byModelMap.set(modelKey, modelExisting);
|
||||
|
||||
const providerKey = entry.provider ?? "unknown";
|
||||
const providerExisting =
|
||||
byProviderMap.get(providerKey) ??
|
||||
({
|
||||
provider: entry.provider,
|
||||
model: undefined,
|
||||
count: 0,
|
||||
totals: emptyTotals(),
|
||||
} as SessionModelUsage);
|
||||
providerExisting.count += entry.count;
|
||||
mergeTotals(providerExisting.totals, entry.totals);
|
||||
byProviderMap.set(providerKey, providerExisting);
|
||||
}
|
||||
}
|
||||
|
||||
if (usage.latency) {
|
||||
const { count, avgMs, minMs, maxMs, p95Ms } = usage.latency;
|
||||
if (count > 0) {
|
||||
latencyTotals.count += count;
|
||||
latencyTotals.sum += avgMs * count;
|
||||
latencyTotals.min = Math.min(latencyTotals.min, minMs);
|
||||
latencyTotals.max = Math.max(latencyTotals.max, maxMs);
|
||||
latencyTotals.p95Max = Math.max(latencyTotals.p95Max, p95Ms);
|
||||
}
|
||||
}
|
||||
|
||||
if (usage.dailyLatency) {
|
||||
for (const day of usage.dailyLatency) {
|
||||
const existing = dailyLatencyMap.get(day.date) ?? {
|
||||
date: day.date,
|
||||
count: 0,
|
||||
sum: 0,
|
||||
min: Number.POSITIVE_INFINITY,
|
||||
max: 0,
|
||||
p95Max: 0,
|
||||
};
|
||||
existing.count += day.count;
|
||||
existing.sum += day.avgMs * day.count;
|
||||
existing.min = Math.min(existing.min, day.minMs);
|
||||
existing.max = Math.max(existing.max, day.maxMs);
|
||||
existing.p95Max = Math.max(existing.p95Max, day.p95Ms);
|
||||
dailyLatencyMap.set(day.date, existing);
|
||||
}
|
||||
}
|
||||
|
||||
if (usage.dailyModelUsage) {
|
||||
for (const entry of usage.dailyModelUsage) {
|
||||
const key = `${entry.date}::${entry.provider ?? "unknown"}::${entry.model ?? "unknown"}`;
|
||||
const existing =
|
||||
modelDailyMap.get(key) ??
|
||||
({
|
||||
date: entry.date,
|
||||
provider: entry.provider,
|
||||
model: entry.model,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
count: 0,
|
||||
} as SessionDailyModelUsage);
|
||||
existing.tokens += entry.tokens;
|
||||
existing.cost += entry.cost;
|
||||
existing.count += entry.count;
|
||||
modelDailyMap.set(key, existing);
|
||||
}
|
||||
}
|
||||
|
||||
if (agentId) {
|
||||
const agentTotals = byAgentMap.get(agentId) ?? emptyTotals();
|
||||
mergeTotals(agentTotals, usage);
|
||||
byAgentMap.set(agentId, agentTotals);
|
||||
}
|
||||
|
||||
if (channel) {
|
||||
const channelTotals = byChannelMap.get(channel) ?? emptyTotals();
|
||||
mergeTotals(channelTotals, usage);
|
||||
byChannelMap.set(channel, channelTotals);
|
||||
}
|
||||
|
||||
if (usage.dailyBreakdown) {
|
||||
for (const day of usage.dailyBreakdown) {
|
||||
const daily = dailyAggregateMap.get(day.date) ?? {
|
||||
date: day.date,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
messages: 0,
|
||||
toolCalls: 0,
|
||||
errors: 0,
|
||||
};
|
||||
daily.tokens += day.tokens;
|
||||
daily.cost += day.cost;
|
||||
dailyAggregateMap.set(day.date, daily);
|
||||
}
|
||||
}
|
||||
|
||||
if (usage.dailyMessageCounts) {
|
||||
for (const day of usage.dailyMessageCounts) {
|
||||
const daily = dailyAggregateMap.get(day.date) ?? {
|
||||
date: day.date,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
messages: 0,
|
||||
toolCalls: 0,
|
||||
errors: 0,
|
||||
};
|
||||
daily.messages += day.total;
|
||||
daily.toolCalls += day.toolCalls;
|
||||
daily.errors += day.errors;
|
||||
dailyAggregateMap.set(day.date, daily);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
key: merged.key,
|
||||
label: merged.label,
|
||||
sessionId: merged.sessionId,
|
||||
updatedAt: merged.updatedAt,
|
||||
agentId,
|
||||
channel,
|
||||
chatType,
|
||||
origin: merged.storeEntry?.origin,
|
||||
modelOverride: merged.storeEntry?.modelOverride,
|
||||
providerOverride: merged.storeEntry?.providerOverride,
|
||||
modelProvider: merged.storeEntry?.modelProvider,
|
||||
model: merged.storeEntry?.model,
|
||||
usage,
|
||||
contextWeight: includeContextWeight
|
||||
? (merged.storeEntry?.systemPromptReport ?? null)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Format dates back to YYYY-MM-DD strings
|
||||
const formatDateStr = (ms: number) => {
|
||||
const d = new Date(ms);
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const aggregates: SessionsUsageAggregates = {
|
||||
messages: aggregateMessages,
|
||||
tools: {
|
||||
totalCalls: Array.from(toolAggregateMap.values()).reduce((sum, count) => sum + count, 0),
|
||||
uniqueTools: toolAggregateMap.size,
|
||||
tools: Array.from(toolAggregateMap.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.toSorted((a, b) => b.count - a.count),
|
||||
},
|
||||
byModel: Array.from(byModelMap.values()).toSorted((a, b) => {
|
||||
const costDiff = b.totals.totalCost - a.totals.totalCost;
|
||||
if (costDiff !== 0) {
|
||||
return costDiff;
|
||||
}
|
||||
return b.totals.totalTokens - a.totals.totalTokens;
|
||||
}),
|
||||
byProvider: Array.from(byProviderMap.values()).toSorted((a, b) => {
|
||||
const costDiff = b.totals.totalCost - a.totals.totalCost;
|
||||
if (costDiff !== 0) {
|
||||
return costDiff;
|
||||
}
|
||||
return b.totals.totalTokens - a.totals.totalTokens;
|
||||
}),
|
||||
byAgent: Array.from(byAgentMap.entries())
|
||||
.map(([id, totals]) => ({ agentId: id, totals }))
|
||||
.toSorted((a, b) => b.totals.totalCost - a.totals.totalCost),
|
||||
byChannel: Array.from(byChannelMap.entries())
|
||||
.map(([name, totals]) => ({ channel: name, totals }))
|
||||
.toSorted((a, b) => b.totals.totalCost - a.totals.totalCost),
|
||||
latency:
|
||||
latencyTotals.count > 0
|
||||
? {
|
||||
count: latencyTotals.count,
|
||||
avgMs: latencyTotals.sum / latencyTotals.count,
|
||||
minMs: latencyTotals.min === Number.POSITIVE_INFINITY ? 0 : latencyTotals.min,
|
||||
maxMs: latencyTotals.max,
|
||||
p95Ms: latencyTotals.p95Max,
|
||||
}
|
||||
: undefined,
|
||||
dailyLatency: Array.from(dailyLatencyMap.values())
|
||||
.map((entry) => ({
|
||||
date: entry.date,
|
||||
count: entry.count,
|
||||
avgMs: entry.count ? entry.sum / entry.count : 0,
|
||||
minMs: entry.min === Number.POSITIVE_INFINITY ? 0 : entry.min,
|
||||
maxMs: entry.max,
|
||||
p95Ms: entry.p95Max,
|
||||
}))
|
||||
.toSorted((a, b) => a.date.localeCompare(b.date)),
|
||||
modelDaily: Array.from(modelDailyMap.values()).toSorted(
|
||||
(a, b) => a.date.localeCompare(b.date) || b.cost - a.cost,
|
||||
),
|
||||
daily: Array.from(dailyAggregateMap.values()).toSorted((a, b) =>
|
||||
a.date.localeCompare(b.date),
|
||||
),
|
||||
};
|
||||
|
||||
const result: SessionsUsageResult = {
|
||||
updatedAt: now,
|
||||
startDate: formatDateStr(startMs),
|
||||
endDate: formatDateStr(endMs),
|
||||
sessions,
|
||||
totals: aggregateTotals,
|
||||
aggregates,
|
||||
};
|
||||
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
"sessions.usage.timeseries": async ({ respond, params }) => {
|
||||
const key = typeof params?.key === "string" ? params.key.trim() : null;
|
||||
if (!key) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "key is required for timeseries"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const { entry } = loadSessionEntry(key);
|
||||
|
||||
// For discovered sessions (not in store), try using key as sessionId directly
|
||||
const sessionId = entry?.sessionId ?? key;
|
||||
const sessionFile = entry?.sessionFile ?? resolveSessionFilePath(key);
|
||||
|
||||
const timeseries = await loadSessionUsageTimeSeries({
|
||||
sessionId,
|
||||
sessionEntry: entry,
|
||||
sessionFile,
|
||||
config,
|
||||
maxPoints: 200,
|
||||
});
|
||||
|
||||
if (!timeseries) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `No transcript found for session: ${key}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
respond(true, timeseries, undefined);
|
||||
},
|
||||
"sessions.usage.logs": async ({ respond, params }) => {
|
||||
const key = typeof params?.key === "string" ? params.key.trim() : null;
|
||||
if (!key) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "key is required for logs"));
|
||||
return;
|
||||
}
|
||||
|
||||
const limit =
|
||||
typeof params?.limit === "number" && Number.isFinite(params.limit)
|
||||
? Math.min(params.limit, 1000)
|
||||
: 200;
|
||||
|
||||
const config = loadConfig();
|
||||
const { entry } = loadSessionEntry(key);
|
||||
|
||||
// For discovered sessions (not in store), try using key as sessionId directly
|
||||
const sessionId = entry?.sessionId ?? key;
|
||||
const sessionFile = entry?.sessionFile ?? resolveSessionFilePath(key);
|
||||
|
||||
const { loadSessionLogs } = await import("../../infra/session-cost-usage.js");
|
||||
const logs = await loadSessionLogs({
|
||||
sessionId,
|
||||
sessionEntry: entry,
|
||||
sessionFile,
|
||||
config,
|
||||
limit,
|
||||
});
|
||||
|
||||
respond(true, { logs: logs ?? [] }, undefined);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -383,6 +383,43 @@ describe("readSessionPreviewItemsFromTranscript", () => {
|
||||
expect(result[1]?.text).toContain("call weather");
|
||||
});
|
||||
|
||||
test("detects tool calls from tool_use/tool_call blocks and toolName field", () => {
|
||||
const sessionId = "preview-session-tools";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||
JSON.stringify({ message: { role: "assistant", content: "Hi" } }),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
toolName: "camera",
|
||||
content: [
|
||||
{ type: "tool_use", name: "read" },
|
||||
{ type: "tool_call", name: "write" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({ message: { role: "assistant", content: "Done" } }),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readSessionPreviewItemsFromTranscript(
|
||||
sessionId,
|
||||
storePath,
|
||||
undefined,
|
||||
undefined,
|
||||
3,
|
||||
120,
|
||||
);
|
||||
|
||||
expect(result.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]);
|
||||
expect(result[1]?.text).toContain("call");
|
||||
expect(result[1]?.text).toContain("camera");
|
||||
expect(result[1]?.text).toContain("read");
|
||||
// Preview text may not list every tool name; it should at least hint there were multiple calls.
|
||||
expect(result[1]?.text).toMatch(/\+\d+/);
|
||||
});
|
||||
|
||||
test("truncates preview text to max chars", () => {
|
||||
const sessionId = "preview-truncate";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { SessionPreviewItem } from "./session-utils.types.js";
|
||||
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
|
||||
import { stripEnvelope } from "./chat-sanitize.js";
|
||||
|
||||
export function readSessionMessages(
|
||||
@@ -292,35 +293,11 @@ function extractPreviewText(message: TranscriptPreviewMessage): string | null {
|
||||
}
|
||||
|
||||
function isToolCall(message: TranscriptPreviewMessage): boolean {
|
||||
if (message.toolName || message.tool_name) {
|
||||
return true;
|
||||
}
|
||||
if (!Array.isArray(message.content)) {
|
||||
return false;
|
||||
}
|
||||
return message.content.some((entry) => {
|
||||
if (entry?.name) {
|
||||
return true;
|
||||
}
|
||||
const raw = typeof entry?.type === "string" ? entry.type.toLowerCase() : "";
|
||||
return raw === "toolcall" || raw === "tool_call";
|
||||
});
|
||||
return hasToolCall(message as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function extractToolNames(message: TranscriptPreviewMessage): string[] {
|
||||
const names: string[] = [];
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const entry of message.content) {
|
||||
if (typeof entry?.name === "string" && entry.name.trim()) {
|
||||
names.push(entry.name.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
const toolName = typeof message.toolName === "string" ? message.toolName : message.tool_name;
|
||||
if (typeof toolName === "string" && toolName.trim()) {
|
||||
names.push(toolName.trim());
|
||||
}
|
||||
return names;
|
||||
return extractToolCallNames(message as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function extractMediaSummary(message: TranscriptPreviewMessage): string | null {
|
||||
|
||||
@@ -3,7 +3,11 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadCostUsageSummary, loadSessionCostSummary } from "./session-cost-usage.js";
|
||||
import {
|
||||
discoverAllSessions,
|
||||
loadCostUsageSummary,
|
||||
loadSessionCostSummary,
|
||||
} from "./session-cost-usage.js";
|
||||
|
||||
describe("session cost usage", () => {
|
||||
it("aggregates daily totals with log cost and pricing fallback", async () => {
|
||||
@@ -140,4 +144,100 @@ describe("session cost usage", () => {
|
||||
expect(summary?.totalTokens).toBe(30);
|
||||
expect(summary?.lastActivity).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("captures message counts, tool usage, and model usage", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-session-meta-"));
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
const start = new Date("2026-02-01T10:00:00.000Z");
|
||||
const end = new Date("2026-02-01T10:05:00.000Z");
|
||||
|
||||
const entries = [
|
||||
{
|
||||
type: "message",
|
||||
timestamp: start.toISOString(),
|
||||
message: {
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
timestamp: end.toISOString(),
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
stopReason: "error",
|
||||
content: [
|
||||
{ type: "text", text: "Checking" },
|
||||
{ type: "tool_use", name: "weather" },
|
||||
{ type: "tool_result", is_error: true },
|
||||
],
|
||||
usage: {
|
||||
input: 12,
|
||||
output: 18,
|
||||
totalTokens: 30,
|
||||
cost: { total: 0.02 },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
entries.map((entry) => JSON.stringify(entry)).join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const summary = await loadSessionCostSummary({ sessionFile });
|
||||
expect(summary?.messageCounts).toEqual({
|
||||
total: 2,
|
||||
user: 1,
|
||||
assistant: 1,
|
||||
toolCalls: 1,
|
||||
toolResults: 1,
|
||||
errors: 2,
|
||||
});
|
||||
expect(summary?.toolUsage?.totalCalls).toBe(1);
|
||||
expect(summary?.toolUsage?.uniqueTools).toBe(1);
|
||||
expect(summary?.toolUsage?.tools[0]?.name).toBe("weather");
|
||||
expect(summary?.modelUsage?.[0]?.provider).toBe("openai");
|
||||
expect(summary?.modelUsage?.[0]?.model).toBe("gpt-5.2");
|
||||
expect(summary?.durationMs).toBe(5 * 60 * 1000);
|
||||
expect(summary?.latency?.count).toBe(1);
|
||||
expect(summary?.latency?.avgMs).toBe(5 * 60 * 1000);
|
||||
expect(summary?.latency?.p95Ms).toBe(5 * 60 * 1000);
|
||||
expect(summary?.dailyLatency?.[0]?.date).toBe("2026-02-01");
|
||||
expect(summary?.dailyLatency?.[0]?.count).toBe(1);
|
||||
expect(summary?.dailyModelUsage?.[0]?.date).toBe("2026-02-01");
|
||||
expect(summary?.dailyModelUsage?.[0]?.model).toBe("gpt-5.2");
|
||||
});
|
||||
|
||||
it("does not exclude sessions with mtime after endMs during discovery", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discover-"));
|
||||
const sessionsDir = path.join(root, "agents", "main", "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
const sessionFile = path.join(sessionsDir, "sess-late.jsonl");
|
||||
await fs.writeFile(sessionFile, "", "utf-8");
|
||||
|
||||
const now = Date.now();
|
||||
await fs.utimes(sessionFile, now / 1000, now / 1000);
|
||||
|
||||
const originalState = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = root;
|
||||
try {
|
||||
const sessions = await discoverAllSessions({
|
||||
startMs: now - 7 * 24 * 60 * 60 * 1000,
|
||||
endMs: now - 24 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0]?.sessionId).toBe("sess-late");
|
||||
} finally {
|
||||
if (originalState === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = originalState;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
66
src/utils/transcript-tools.test.ts
Normal file
66
src/utils/transcript-tools.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { countToolResults, extractToolCallNames, hasToolCall } from "./transcript-tools.js";
|
||||
|
||||
describe("transcript-tools", () => {
|
||||
describe("extractToolCallNames", () => {
|
||||
it("extracts tool name from message.toolName/tool_name", () => {
|
||||
expect(extractToolCallNames({ toolName: " weather " })).toEqual(["weather"]);
|
||||
expect(extractToolCallNames({ tool_name: "notes" })).toEqual(["notes"]);
|
||||
});
|
||||
|
||||
it("extracts tool call names from content blocks (tool_use/toolcall/tool_call)", () => {
|
||||
const names = extractToolCallNames({
|
||||
content: [
|
||||
{ type: "text", text: "hi" },
|
||||
{ type: "tool_use", name: "read" },
|
||||
{ type: "toolcall", name: "exec" },
|
||||
{ type: "tool_call", name: "write" },
|
||||
],
|
||||
});
|
||||
expect(new Set(names)).toEqual(new Set(["read", "exec", "write"]));
|
||||
});
|
||||
|
||||
it("normalizes type and trims names; de-dupes", () => {
|
||||
const names = extractToolCallNames({
|
||||
content: [
|
||||
{ type: " TOOL_CALL ", name: " read " },
|
||||
{ type: "tool_call", name: "read" },
|
||||
{ type: "tool_call", name: "" },
|
||||
],
|
||||
toolName: "read",
|
||||
});
|
||||
expect(names).toEqual(["read"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasToolCall", () => {
|
||||
it("returns true when tool call names exist", () => {
|
||||
expect(hasToolCall({ toolName: "weather" })).toBe(true);
|
||||
expect(hasToolCall({ content: [{ type: "tool_use", name: "read" }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no tool calls exist", () => {
|
||||
expect(hasToolCall({})).toBe(false);
|
||||
expect(hasToolCall({ content: [{ type: "text", text: "hi" }] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countToolResults", () => {
|
||||
it("counts tool_result blocks and tool_result_error blocks; tracks errors via is_error", () => {
|
||||
expect(
|
||||
countToolResults({
|
||||
content: [
|
||||
{ type: "tool_result" },
|
||||
{ type: "tool_result", is_error: true },
|
||||
{ type: "tool_result_error" },
|
||||
{ type: "text", text: "ignore" },
|
||||
],
|
||||
}),
|
||||
).toEqual({ total: 3, errors: 1 });
|
||||
});
|
||||
|
||||
it("handles non-array content", () => {
|
||||
expect(countToolResults({ content: "nope" })).toEqual({ total: 0, errors: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
73
src/utils/transcript-tools.ts
Normal file
73
src/utils/transcript-tools.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
type ToolResultCounts = {
|
||||
total: number;
|
||||
errors: number;
|
||||
};
|
||||
|
||||
const TOOL_CALL_TYPES = new Set(["tool_use", "toolcall", "tool_call"]);
|
||||
const TOOL_RESULT_TYPES = new Set(["tool_result", "tool_result_error"]);
|
||||
|
||||
const normalizeType = (value: unknown): string => {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim().toLowerCase();
|
||||
};
|
||||
|
||||
export const extractToolCallNames = (message: Record<string, unknown>): string[] => {
|
||||
const names = new Set<string>();
|
||||
const toolNameRaw = message.toolName ?? message.tool_name;
|
||||
if (typeof toolNameRaw === "string" && toolNameRaw.trim()) {
|
||||
names.add(toolNameRaw.trim());
|
||||
}
|
||||
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) {
|
||||
return Array.from(names);
|
||||
}
|
||||
|
||||
for (const entry of content) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const block = entry as Record<string, unknown>;
|
||||
const type = normalizeType(block.type);
|
||||
if (!TOOL_CALL_TYPES.has(type)) {
|
||||
continue;
|
||||
}
|
||||
const name = block.name;
|
||||
if (typeof name === "string" && name.trim()) {
|
||||
names.add(name.trim());
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(names);
|
||||
};
|
||||
|
||||
export const hasToolCall = (message: Record<string, unknown>): boolean =>
|
||||
extractToolCallNames(message).length > 0;
|
||||
|
||||
export const countToolResults = (message: Record<string, unknown>): ToolResultCounts => {
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) {
|
||||
return { total: 0, errors: 0 };
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
let errors = 0;
|
||||
for (const entry of content) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const block = entry as Record<string, unknown>;
|
||||
const type = normalizeType(block.type);
|
||||
if (!TOOL_RESULT_TYPES.has(type)) {
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
if (block.is_error === true) {
|
||||
errors += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { total, errors };
|
||||
};
|
||||
Reference in New Issue
Block a user