a9c71cd905
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
160 lines
5.9 KiB
JavaScript
160 lines
5.9 KiB
JavaScript
/**
|
||
* @file Tests for replaceTokenUsage's compaction baseline semantics, and for
|
||
* the long-context subsets written beside it (high-water mark, exact reset).
|
||
*
|
||
* The effective total for a bucket is `live + baseline`. It must behave as a
|
||
* monotonic HIGH-WATER MARK: never decrease (so a compaction that shrinks the
|
||
* transcript doesn't lose usage), but never inflate past the largest value ever
|
||
* seen (so two writers hitting the same bucket with different scopes — the live
|
||
* hook writer stores main-only tokens, importSession stores main+subagents —
|
||
* can't ratchet the baseline upward on every downward fluctuation).
|
||
*
|
||
* Regression guard for the runaway that inflated one 26-day session's baseline
|
||
* to ~11× its real usage (dashboard total $22.5k vs true ~$15.2k).
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
const { describe, it, before, after } = require("node:test");
|
||
const assert = require("node:assert/strict");
|
||
const path = require("path");
|
||
const fs = require("fs");
|
||
const os = require("os");
|
||
|
||
const TEST_DB = path.join(os.tmpdir(), `dashboard-token-baseline-${Date.now()}-${process.pid}.db`);
|
||
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
||
|
||
const dbModule = require("../db");
|
||
const { db, stmts } = dbModule;
|
||
|
||
after(() => {
|
||
if (db) db.close();
|
||
for (const suffix of ["", "-wal", "-shm"]) {
|
||
try {
|
||
fs.unlinkSync(TEST_DB + suffix);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
});
|
||
|
||
// Write a cache_read value for a fixed bucket; return effective (live+baseline).
|
||
function writeCacheRead(sessionId, cacheRead) {
|
||
stmts.replaceTokenUsage.run(
|
||
sessionId,
|
||
"claude-opus-4-8",
|
||
"standard",
|
||
"global",
|
||
"standard",
|
||
0, // input
|
||
0, // output
|
||
cacheRead,
|
||
0, // cache_write
|
||
0, // cache_write_1h
|
||
0, // web_search
|
||
0, // web_fetch
|
||
0 // code_execution
|
||
);
|
||
}
|
||
function effectiveCacheRead(sessionId) {
|
||
const r = stmts.getTokensBySession.all(sessionId).find((x) => x.model === "claude-opus-4-8");
|
||
return r ? r.cache_read_tokens : 0;
|
||
}
|
||
function rawRow(sessionId) {
|
||
return db
|
||
.prepare(
|
||
"SELECT cache_read_tokens, baseline_cache_read FROM token_usage WHERE session_id = ? AND model = 'claude-opus-4-8'"
|
||
)
|
||
.get(sessionId);
|
||
}
|
||
|
||
describe("replaceTokenUsage — high-water-mark baseline", () => {
|
||
before(() => {
|
||
for (const id of ["hw-grow", "hw-idem", "hw-shrink", "hw-altern", "hw-recover"]) {
|
||
try {
|
||
stmts.insertSession.run(id, "t", "active", "/tmp", null, null);
|
||
} catch {
|
||
/* exists */
|
||
}
|
||
}
|
||
});
|
||
|
||
it("monotonic growth: effective follows the latest, no baseline", () => {
|
||
writeCacheRead("hw-grow", 100);
|
||
assert.equal(effectiveCacheRead("hw-grow"), 100);
|
||
writeCacheRead("hw-grow", 300);
|
||
assert.equal(effectiveCacheRead("hw-grow"), 300);
|
||
assert.equal(rawRow("hw-grow").baseline_cache_read, 0);
|
||
});
|
||
|
||
it("idempotent: rewriting the same value never changes effective", () => {
|
||
writeCacheRead("hw-idem", 500);
|
||
writeCacheRead("hw-idem", 500);
|
||
writeCacheRead("hw-idem", 500);
|
||
assert.equal(effectiveCacheRead("hw-idem"), 500);
|
||
assert.equal(rawRow("hw-idem").baseline_cache_read, 0);
|
||
});
|
||
|
||
it("decrease preserves the max (compaction never loses usage)", () => {
|
||
writeCacheRead("hw-shrink", 300);
|
||
writeCacheRead("hw-shrink", 100); // transcript shrank
|
||
assert.equal(effectiveCacheRead("hw-shrink"), 300, "effective must not drop below the peak");
|
||
const row = rawRow("hw-shrink");
|
||
assert.equal(row.cache_read_tokens, 100);
|
||
assert.equal(row.baseline_cache_read, 200); // 100 live + 200 baseline = 300
|
||
});
|
||
|
||
it("writer alternation does NOT run away (the bug)", () => {
|
||
// Two writers: 'big' (main+subagents) and 'small' (main-only) on one bucket.
|
||
for (let i = 0; i < 20; i++) {
|
||
writeCacheRead("hw-altern", 800); // big writer
|
||
writeCacheRead("hw-altern", 700); // small writer
|
||
}
|
||
// Effective must stay pinned at the true max (800), NOT accumulate.
|
||
assert.equal(effectiveCacheRead("hw-altern"), 800, "effective must stay at the peak, not grow");
|
||
assert.equal(rawRow("hw-altern").baseline_cache_read, 100); // 700 + 100 = 800
|
||
});
|
||
|
||
it("recovers upward: a new higher value zeroes stale baseline", () => {
|
||
writeCacheRead("hw-recover", 300);
|
||
writeCacheRead("hw-recover", 100); // baseline=200, eff=300
|
||
writeCacheRead("hw-recover", 500); // new peak
|
||
assert.equal(effectiveCacheRead("hw-recover"), 500);
|
||
assert.equal(rawRow("hw-recover").baseline_cache_read, 0);
|
||
});
|
||
});
|
||
|
||
describe("long-context subsets", () => {
|
||
const { writeSessionTokens } = require("../../scripts/import-history");
|
||
const { emptyBucket, accumulateBucket, extractUsageFields } = require("../lib/token-usage");
|
||
const longRow = (sid) =>
|
||
db
|
||
.prepare("SELECT input_tokens, long_input_tokens FROM token_usage WHERE session_id = ?")
|
||
.get(sid);
|
||
const bucketOf = (...usages) => {
|
||
const b = emptyBucket("claude-haiku-5-5", "standard", "global", "standard");
|
||
for (const u of usages) accumulateBucket(b, extractUsageFields(u));
|
||
return { k: b };
|
||
};
|
||
|
||
before(() => {
|
||
db.prepare(
|
||
"INSERT OR IGNORE INTO sessions (id, name, status) VALUES ('long-1', 'x', 'active')"
|
||
).run();
|
||
});
|
||
|
||
it("stores the long part as a subset of the same row and keeps its high-water mark", () => {
|
||
const short = { input_tokens: 1_000 };
|
||
const long = { input_tokens: 150_000 };
|
||
writeSessionTokens(dbModule, "long-1", bucketOf(short, long));
|
||
assert.deepEqual(longRow("long-1"), { input_tokens: 151_000, long_input_tokens: 150_000 });
|
||
// A smaller-scope writer never takes the subset back.
|
||
writeSessionTokens(dbModule, "long-1", bucketOf(short));
|
||
assert.equal(longRow("long-1").long_input_tokens, 150_000);
|
||
});
|
||
|
||
it("lets the exact reconcile path clear a stale subset", () => {
|
||
writeSessionTokens(dbModule, "long-1", bucketOf({ input_tokens: 1_000 }), { exact: true });
|
||
assert.deepEqual(longRow("long-1"), { input_tokens: 1_000, long_input_tokens: 0 });
|
||
});
|
||
});
|