mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
feat: persistent token tracking with global stats aggregation
- Add restoreTokens() method to Session class for recovery after restart - Add GlobalStats type for cumulative usage tracking across all sessions - Accumulate tokens from deleted sessions into global stats - Track lifetime session count with incrementSessionsCreated() - Add /api/stats endpoint for global stats - Include globalStats in /api/status response - Frontend shows aggregate tokens + cost in header - Fix respawn controller config to filter undefined values - Add 7 new tests for global stats functionality (v0.1343) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs.
|
||||
|
||||
**Version**: 0.1342
|
||||
**Version**: 0.1343
|
||||
|
||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudeman",
|
||||
"version": "0.1342",
|
||||
"version": "0.1343",
|
||||
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -662,7 +662,11 @@ export class RespawnController extends EventEmitter {
|
||||
constructor(session: Session, config: Partial<RespawnConfig> = {}) {
|
||||
super();
|
||||
this.session = session;
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
// Filter out undefined values from config to prevent overwriting defaults
|
||||
const filteredConfig = Object.fromEntries(
|
||||
Object.entries(config).filter(([, v]) => v !== undefined)
|
||||
) as Partial<RespawnConfig>;
|
||||
this.config = { ...DEFAULT_CONFIG, ...filteredConfig };
|
||||
this.aiChecker = new AiIdleChecker(session.id, {
|
||||
enabled: this.config.aiIdleCheckEnabled,
|
||||
model: this.config.aiIdleCheckModel,
|
||||
@@ -2169,7 +2173,11 @@ export class RespawnController extends EventEmitter {
|
||||
* @fires log - With updated config details
|
||||
*/
|
||||
updateConfig(config: Partial<RespawnConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
// Filter out undefined values to prevent overwriting existing config with undefined
|
||||
const filteredConfig = Object.fromEntries(
|
||||
Object.entries(config).filter(([, v]) => v !== undefined)
|
||||
) as Partial<RespawnConfig>;
|
||||
this.config = { ...this.config, ...filteredConfig };
|
||||
|
||||
// Sync AI checker config if relevant fields changed
|
||||
if (config.aiIdleCheckEnabled !== undefined || config.aiIdleCheckModel !== undefined ||
|
||||
|
||||
@@ -571,6 +571,16 @@ export class Session extends EventEmitter {
|
||||
return this._totalOutputTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore token and cost values from saved state.
|
||||
* Called when recovering sessions after server restart.
|
||||
*/
|
||||
restoreTokens(inputTokens: number, outputTokens: number, totalCost: number): void {
|
||||
this._totalInputTokens = inputTokens;
|
||||
this._totalOutputTokens = outputTokens;
|
||||
this._totalCost = totalCost;
|
||||
}
|
||||
|
||||
get autoClearThreshold(): number {
|
||||
return this._autoClearThreshold;
|
||||
}
|
||||
|
||||
+65
-1
@@ -17,7 +17,7 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState } from './types.js';
|
||||
import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState, GlobalStats, createInitialGlobalStats } from './types.js';
|
||||
|
||||
/** Debounce delay for batching state writes (ms) */
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
@@ -208,6 +208,70 @@ export class StateStore {
|
||||
this.saveRalphStatesNow();
|
||||
}
|
||||
|
||||
// ========== Global Stats Methods ==========
|
||||
|
||||
/** Returns global stats, creating initial stats if needed. */
|
||||
getGlobalStats(): GlobalStats {
|
||||
if (!this.state.globalStats) {
|
||||
this.state.globalStats = createInitialGlobalStats();
|
||||
}
|
||||
return this.state.globalStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds tokens and cost to global stats.
|
||||
* Call when a session is deleted to preserve its usage in lifetime stats.
|
||||
*/
|
||||
addToGlobalStats(inputTokens: number, outputTokens: number, cost: number): void {
|
||||
const stats = this.getGlobalStats();
|
||||
stats.totalInputTokens += inputTokens;
|
||||
stats.totalOutputTokens += outputTokens;
|
||||
stats.totalCost += cost;
|
||||
stats.lastUpdatedAt = Date.now();
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Increments the total sessions created counter. */
|
||||
incrementSessionsCreated(): void {
|
||||
const stats = this.getGlobalStats();
|
||||
stats.totalSessionsCreated += 1;
|
||||
stats.lastUpdatedAt = Date.now();
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns aggregate stats combining global (deleted sessions) + active sessions.
|
||||
* @param activeSessions Map of active session states
|
||||
*/
|
||||
getAggregateStats(activeSessions: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }>): {
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
totalCost: number;
|
||||
totalSessionsCreated: number;
|
||||
activeSessionsCount: number;
|
||||
} {
|
||||
const global = this.getGlobalStats();
|
||||
let activeInput = 0;
|
||||
let activeOutput = 0;
|
||||
let activeCost = 0;
|
||||
let activeCount = 0;
|
||||
|
||||
for (const session of Object.values(activeSessions)) {
|
||||
activeInput += session.inputTokens ?? 0;
|
||||
activeOutput += session.outputTokens ?? 0;
|
||||
activeCost += session.totalCost ?? 0;
|
||||
activeCount++;
|
||||
}
|
||||
|
||||
return {
|
||||
totalInputTokens: global.totalInputTokens + activeInput,
|
||||
totalOutputTokens: global.totalOutputTokens + activeOutput,
|
||||
totalCost: global.totalCost + activeCost,
|
||||
totalSessionsCreated: global.totalSessionsCreated,
|
||||
activeSessionsCount: activeCount,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Inner State Methods (Ralph Loop tracking) ==========
|
||||
|
||||
private loadRalphStates(): void {
|
||||
|
||||
@@ -88,6 +88,27 @@ export interface SessionState {
|
||||
childAgentIds?: string[];
|
||||
}
|
||||
|
||||
// ========== Global Stats Types ==========
|
||||
|
||||
/**
|
||||
* Global statistics across all sessions (including deleted ones).
|
||||
* Persisted to track cumulative usage over time.
|
||||
*/
|
||||
export interface GlobalStats {
|
||||
/** Total input tokens used across all sessions */
|
||||
totalInputTokens: number;
|
||||
/** Total output tokens used across all sessions */
|
||||
totalOutputTokens: number;
|
||||
/** Total cost in USD across all sessions */
|
||||
totalCost: number;
|
||||
/** Total number of sessions created (lifetime) */
|
||||
totalSessionsCreated: number;
|
||||
/** Timestamp when stats were first recorded */
|
||||
firstRecordedAt: number;
|
||||
/** Timestamp of last update */
|
||||
lastUpdatedAt: number;
|
||||
}
|
||||
|
||||
// ========== Task Types ==========
|
||||
|
||||
/**
|
||||
@@ -178,6 +199,8 @@ export interface AppState {
|
||||
ralphLoop: RalphLoopState;
|
||||
/** Application configuration */
|
||||
config: AppConfig;
|
||||
/** Global statistics (cumulative across all sessions) */
|
||||
globalStats?: GlobalStats;
|
||||
}
|
||||
|
||||
// ========== Respawn Controller Types ==========
|
||||
@@ -744,6 +767,23 @@ export function createInitialState(): AppState {
|
||||
lastCheckAt: null,
|
||||
},
|
||||
config: { ...DEFAULT_CONFIG },
|
||||
globalStats: createInitialGlobalStats(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates initial global stats object
|
||||
* @returns Fresh global stats with zero values
|
||||
*/
|
||||
export function createInitialGlobalStats(): GlobalStats {
|
||||
const now = Date.now();
|
||||
return {
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCost: 0,
|
||||
totalSessionsCreated: 0,
|
||||
firstRecordedAt: now,
|
||||
lastUpdatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+27
-6
@@ -355,6 +355,7 @@ class ClaudemanApp {
|
||||
this.cases = [];
|
||||
this.currentRun = null;
|
||||
this.totalTokens = 0;
|
||||
this.globalStats = null; // Global token/cost stats across all sessions
|
||||
this.eventSource = null;
|
||||
this.terminal = null;
|
||||
this.fitAddon = null;
|
||||
@@ -1298,6 +1299,11 @@ class ClaudemanApp {
|
||||
this.respawnStatus = data.respawnStatus;
|
||||
}
|
||||
|
||||
// Store global stats for aggregate tracking
|
||||
if (data.globalStats) {
|
||||
this.globalStats = data.globalStats;
|
||||
}
|
||||
|
||||
this.totalCost = data.sessions.reduce((sum, s) => sum + (s.totalCost || 0), 0);
|
||||
this.totalCost += data.scheduledRuns.reduce((sum, r) => sum + (r.totalCost || 0), 0);
|
||||
|
||||
@@ -2404,15 +2410,30 @@ class ClaudemanApp {
|
||||
}
|
||||
|
||||
updateTokens() {
|
||||
// Use global stats if available (includes deleted sessions)
|
||||
let total = 0;
|
||||
this.sessions.forEach(s => {
|
||||
if (s.tokens) {
|
||||
total += s.tokens.total || 0;
|
||||
}
|
||||
});
|
||||
if (this.globalStats) {
|
||||
total = this.globalStats.totalInputTokens + this.globalStats.totalOutputTokens;
|
||||
} else {
|
||||
// Fallback to active sessions only
|
||||
this.sessions.forEach(s => {
|
||||
if (s.tokens) {
|
||||
total += s.tokens.total || 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
this.totalTokens = total;
|
||||
const display = total >= 1000 ? `${(total / 1000).toFixed(1)}k` : total;
|
||||
this.$('headerTokens').textContent = `${display} tokens`;
|
||||
|
||||
// Show global cost if available
|
||||
const costDisplay = this.globalStats ? `$${this.globalStats.totalCost.toFixed(2)}` : '';
|
||||
const tokenEl = this.$('headerTokens');
|
||||
if (tokenEl) {
|
||||
tokenEl.textContent = costDisplay ? `${display} tokens · ${costDisplay}` : `${display} tokens`;
|
||||
tokenEl.title = this.globalStats
|
||||
? `Lifetime: ${this.globalStats.totalSessionsCreated} sessions created`
|
||||
: 'Token usage across active sessions';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Session Options Modal ==========
|
||||
|
||||
+60
-8
@@ -319,6 +319,23 @@ export class WebServer extends EventEmitter {
|
||||
// API Routes
|
||||
this.app.get('/api/status', async () => this.getFullState());
|
||||
|
||||
// Global stats endpoint
|
||||
this.app.get('/api/stats', async () => {
|
||||
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
activeSessionTokens[sessionId] = {
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
stats: this.store.getAggregateStats(activeSessionTokens),
|
||||
raw: this.store.getGlobalStats(),
|
||||
};
|
||||
});
|
||||
|
||||
this.app.get('/api/config', async () => {
|
||||
return { success: true, config: this.store.getConfig() };
|
||||
});
|
||||
@@ -352,6 +369,7 @@ export class WebServer extends EventEmitter {
|
||||
});
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
this.store.incrementSessionsCreated();
|
||||
this.persistSessionState(session);
|
||||
this.setupSessionListeners(session);
|
||||
|
||||
@@ -821,15 +839,15 @@ export class WebServer extends EventEmitter {
|
||||
autoAcceptPrompts: config.autoAcceptPrompts ?? currentConfig?.autoAcceptPrompts ?? true,
|
||||
autoAcceptDelayMs: config.autoAcceptDelayMs ?? currentConfig?.autoAcceptDelayMs ?? 8000,
|
||||
aiIdleCheckEnabled: config.aiIdleCheckEnabled ?? currentConfig?.aiIdleCheckEnabled ?? true,
|
||||
aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel,
|
||||
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext,
|
||||
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs,
|
||||
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs,
|
||||
aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? 'claude-opus-4-5-20251101',
|
||||
aiIdleCheckMaxContext: config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? 16000,
|
||||
aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000,
|
||||
aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000,
|
||||
aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true,
|
||||
aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel,
|
||||
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext,
|
||||
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs,
|
||||
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs,
|
||||
aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? 'claude-opus-4-5-20251101',
|
||||
aiPlanCheckMaxContext: config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? 8000,
|
||||
aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000,
|
||||
aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000,
|
||||
durationMinutes: currentConfig?.durationMinutes,
|
||||
};
|
||||
this.screenManager.updateRespawnConfig(id, merged);
|
||||
@@ -1024,6 +1042,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
const session = new Session({ workingDir: dir });
|
||||
this.sessions.set(session.id, session);
|
||||
this.store.incrementSessionsCreated();
|
||||
this.persistSessionState(session);
|
||||
this.setupSessionListeners(session);
|
||||
|
||||
@@ -1321,6 +1340,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
this.store.incrementSessionsCreated();
|
||||
this.persistSessionState(session);
|
||||
this.setupSessionListeners(session);
|
||||
this.broadcast('session:created', session.toDetailedState());
|
||||
@@ -1688,6 +1708,13 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
// Stop session and remove listeners
|
||||
if (session) {
|
||||
// Accumulate tokens to global stats before removing session
|
||||
// This preserves lifetime usage even after sessions are deleted
|
||||
if (killScreen && (session.inputTokens > 0 || session.outputTokens > 0 || session.totalCost > 0)) {
|
||||
this.store.addToGlobalStats(session.inputTokens, session.outputTokens, session.totalCost);
|
||||
console.log(`[Server] Added to global stats: ${session.inputTokens + session.outputTokens} tokens, $${session.totalCost.toFixed(4)} from session ${sessionId}`);
|
||||
}
|
||||
|
||||
session.removeAllListeners();
|
||||
await session.stop(killScreen);
|
||||
this.sessions.delete(sessionId);
|
||||
@@ -1909,6 +1936,7 @@ export class WebServer extends EventEmitter {
|
||||
});
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
this.store.incrementSessionsCreated();
|
||||
this.setupSessionListeners(session);
|
||||
session.parentAgentId = name;
|
||||
|
||||
@@ -2088,6 +2116,7 @@ export class WebServer extends EventEmitter {
|
||||
// Create a session for this iteration
|
||||
session = new Session({ workingDir: run.workingDir });
|
||||
this.sessions.set(session.id, session);
|
||||
this.store.incrementSessionsCreated();
|
||||
this.persistSessionState(session);
|
||||
this.setupSessionListeners(session);
|
||||
run.sessionId = session.id;
|
||||
@@ -2191,11 +2220,22 @@ export class WebServer extends EventEmitter {
|
||||
respawnStatus[sessionId] = controller.getStatus();
|
||||
}
|
||||
|
||||
// Build active sessions token map for aggregate calculation
|
||||
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
activeSessionTokens[sessionId] = {
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
version: APP_VERSION,
|
||||
sessions: this.getSessionsState(),
|
||||
scheduledRuns: Array.from(this.scheduledRuns.values()),
|
||||
respawnStatus,
|
||||
globalStats: this.store.getAggregateStats(activeSessionTokens),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -2449,6 +2489,18 @@ export class WebServer extends EventEmitter {
|
||||
savedState.autoClearThreshold
|
||||
);
|
||||
}
|
||||
// Token tracking
|
||||
if (savedState.inputTokens !== undefined || savedState.outputTokens !== undefined || savedState.totalCost !== undefined) {
|
||||
session.restoreTokens(
|
||||
savedState.inputTokens ?? 0,
|
||||
savedState.outputTokens ?? 0,
|
||||
savedState.totalCost ?? 0
|
||||
);
|
||||
const totalTokens = (savedState.inputTokens ?? 0) + (savedState.outputTokens ?? 0);
|
||||
if (totalTokens > 0) {
|
||||
console.log(`[Server] Restored tokens for session ${session.id}: ${totalTokens} tokens, $${(savedState.totalCost ?? 0).toFixed(4)}`);
|
||||
}
|
||||
}
|
||||
// Ralph / Todo tracker
|
||||
if (savedState.ralphEnabled) {
|
||||
session.ralphTracker.enable();
|
||||
|
||||
@@ -248,6 +248,31 @@ describe('RespawnController', () => {
|
||||
const config = controller.getConfig();
|
||||
expect(config.idleTimeoutMs).toBe(originalTimeout);
|
||||
});
|
||||
|
||||
it('should not override defaults with explicit undefined values in constructor', () => {
|
||||
const configWithUndefined = {
|
||||
idleTimeoutMs: 5000,
|
||||
aiIdleCheckTimeoutMs: undefined,
|
||||
aiIdleCheckModel: undefined,
|
||||
} as Partial<RespawnConfig>;
|
||||
|
||||
const newController = new RespawnController(session as unknown as Session, configWithUndefined);
|
||||
const config = newController.getConfig();
|
||||
|
||||
// Explicit undefined should not override defaults
|
||||
expect(config.aiIdleCheckTimeoutMs).toBe(90000); // default value
|
||||
expect(config.aiIdleCheckModel).toBe('claude-opus-4-5-20251101'); // default value
|
||||
expect(config.idleTimeoutMs).toBe(5000); // explicit value should be preserved
|
||||
newController.stop();
|
||||
});
|
||||
|
||||
it('should not override existing config with explicit undefined in updateConfig', () => {
|
||||
const originalTimeout = controller.getConfig().aiIdleCheckTimeoutMs;
|
||||
controller.updateConfig({ aiIdleCheckTimeoutMs: undefined } as Partial<RespawnConfig>);
|
||||
|
||||
const config = controller.getConfig();
|
||||
expect(config.aiIdleCheckTimeoutMs).toBe(originalTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status', () => {
|
||||
|
||||
@@ -331,6 +331,89 @@ describe('StateStore', () => {
|
||||
expect(store2.getRalphState('session-1')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('global stats', () => {
|
||||
it('should initialize global stats with zero values', () => {
|
||||
const store = new StateStore(testFilePath);
|
||||
const stats = store.getGlobalStats();
|
||||
|
||||
expect(stats.totalInputTokens).toBe(0);
|
||||
expect(stats.totalOutputTokens).toBe(0);
|
||||
expect(stats.totalCost).toBe(0);
|
||||
expect(stats.totalSessionsCreated).toBe(0);
|
||||
expect(stats.firstRecordedAt).toBeDefined();
|
||||
expect(stats.lastUpdatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should add to global stats', () => {
|
||||
const store = new StateStore(testFilePath);
|
||||
|
||||
store.addToGlobalStats(1000, 500, 0.05);
|
||||
|
||||
const stats = store.getGlobalStats();
|
||||
expect(stats.totalInputTokens).toBe(1000);
|
||||
expect(stats.totalOutputTokens).toBe(500);
|
||||
expect(stats.totalCost).toBe(0.05);
|
||||
});
|
||||
|
||||
it('should accumulate global stats across multiple calls', () => {
|
||||
const store = new StateStore(testFilePath);
|
||||
|
||||
store.addToGlobalStats(1000, 500, 0.05);
|
||||
store.addToGlobalStats(2000, 1000, 0.10);
|
||||
|
||||
const stats = store.getGlobalStats();
|
||||
expect(stats.totalInputTokens).toBe(3000);
|
||||
expect(stats.totalOutputTokens).toBe(1500);
|
||||
expect(stats.totalCost).toBeCloseTo(0.15, 10);
|
||||
});
|
||||
|
||||
it('should increment sessions created counter', () => {
|
||||
const store = new StateStore(testFilePath);
|
||||
|
||||
store.incrementSessionsCreated();
|
||||
store.incrementSessionsCreated();
|
||||
store.incrementSessionsCreated();
|
||||
|
||||
const stats = store.getGlobalStats();
|
||||
expect(stats.totalSessionsCreated).toBe(3);
|
||||
});
|
||||
|
||||
it('should compute aggregate stats combining global and active sessions', () => {
|
||||
const store = new StateStore(testFilePath);
|
||||
|
||||
// Add global stats (from deleted sessions)
|
||||
store.addToGlobalStats(5000, 2500, 0.25);
|
||||
|
||||
// Simulate active sessions
|
||||
const activeSessions = {
|
||||
'session-1': { inputTokens: 1000, outputTokens: 500, totalCost: 0.05 },
|
||||
'session-2': { inputTokens: 2000, outputTokens: 1000, totalCost: 0.10 },
|
||||
};
|
||||
|
||||
const aggregate = store.getAggregateStats(activeSessions);
|
||||
|
||||
expect(aggregate.totalInputTokens).toBe(8000); // 5000 + 1000 + 2000
|
||||
expect(aggregate.totalOutputTokens).toBe(4000); // 2500 + 500 + 1000
|
||||
expect(aggregate.totalCost).toBeCloseTo(0.40, 10); // 0.25 + 0.05 + 0.10
|
||||
expect(aggregate.activeSessionsCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should persist global stats across instances', () => {
|
||||
const store1 = new StateStore(testFilePath);
|
||||
store1.addToGlobalStats(10000, 5000, 0.50);
|
||||
store1.incrementSessionsCreated();
|
||||
store1.flushAll();
|
||||
|
||||
const store2 = new StateStore(testFilePath);
|
||||
const stats = store2.getGlobalStats();
|
||||
|
||||
expect(stats.totalInputTokens).toBe(10000);
|
||||
expect(stats.totalOutputTokens).toBe(5000);
|
||||
expect(stats.totalCost).toBe(0.50);
|
||||
expect(stats.totalSessionsCreated).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions to create mock state objects
|
||||
|
||||
Reference in New Issue
Block a user