Skip to content

Commit 91bf0c5

Browse files
ndemiancclaude
andcommitted
fix(ai): clamp a negative balance, refresh stale comments, test the formatters (PR #34 review)
All three review points were real. 1. creditBalance() could render a negative ("-2 left"). An overage pushes the remaining balance below zero in the ledger, and the dollar formatter this replaced clamped at $0.00 — that clamp was lost in the port. Restored. (The same comment also flagged creditCost() rounding sub-0.05 costs to "0"; that half was already fixed in 7d6c711, which added the "<0.1" floor.) 2. The comment block above the helpers still described the deleted money() behaviour ("$0.00" / "<$0.01"), and the response-bar comment still read "model · $cost · $left". Both now describe credits. 3. The helpers had no tests, in a repo that already extracts and tests shipped chat.html functions. Added creditFormat.test.js on the narrativeUi/shHighlight pattern — it EXTRACTS the real functions from chat.html rather than copying them, and asserts MICROS_PER_CREDIT is still 10000 so the editor can't drift from the website's conversion. To make extraction work, toCredits and creditBalance became multi-line (the convention slices to "\n }"). Verified: 23 suites, 0 failures; creditFormat is 7 cases. Both fixes are mutation-checked — removing the clamp fails, and restoring the unary + fails. The suite also caught an arithmetic error in my own first draft ($0.70 is 70 credits, not 7.0), which is a fair advertisement for the reviewer's point. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7d6c711 commit 91bf0c5

2 files changed

Lines changed: 124 additions & 14 deletions

File tree

extensions/levelcode-ai/media/chat.html

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2419,28 +2419,34 @@
24192419
function modelLabel(){
24202420
try { const t = (document.getElementById('model').textContent || 'Auto').replace('▾', '').trim(); return t || 'Auto'; } catch(e){ return 'Auto'; }
24212421
}
2422-
// [LevelCode] Retail micro-$ → a human figure. Sub-cent turns are common once prompt caching kicks
2423-
// in, and "$0.00" would read as free/broken — so floor them at "<$0.01" instead of rounding away.
2424-
// Credits are the in-product spending unit: $1 = 100 credits, so 1 credit = 10_000 retail micro-$.
2425-
// The gateway sends RETAIL micro-$ (Levelcode.retail_micros), the same figures the account dashboard
2426-
// renders — so converting here with the same rule keeps the editor and the website in agreement.
2422+
// [LevelCode] Credits are the in-product spending unit: $1 = 100 credits, so 1 credit = 10_000
2423+
// retail micro-$. The gateway sends RETAIL micro-$ (Levelcode.retail_micros) — the same figures the
2424+
// account dashboard renders — so converting here with the same rule keeps the editor and the website
2425+
// showing one number. Pinned by test/creditFormat.test.js, which extracts these very functions.
24272426
const MICROS_PER_CREDIT = 10000;
2428-
function toCredits(micros){ const c = Number(micros) / MICROS_PER_CREDIT; return isFinite(c) ? c : 0; }
2429-
// A balance reads as a whole credit count. A single run, though, can cost well under one credit (a
2430-
// cheap-model turn is ~0.4), and rounding that to "0" would read as free — so small costs keep one
2431-
// decimal until they're big enough not to need it.
2432-
function creditBalance(micros){ return Math.round(toCredits(micros)).toLocaleString(); }
2427+
function toCredits(micros){
2428+
const c = Number(micros) / MICROS_PER_CREDIT;
2429+
return isFinite(c) ? c : 0;
2430+
}
2431+
// A balance reads as a whole credit count. Clamped at zero: an overage can push the remaining
2432+
// balance negative, and "-2 left" is a worse thing to put in front of someone than "0 left" (the
2433+
// dollar formatter this replaced clamped the same way).
2434+
function creditBalance(micros){
2435+
const c = toCredits(micros);
2436+
return (c > 0 ? Math.round(c) : 0).toLocaleString();
2437+
}
2438+
// A single run can cost well under one credit (a cheap-model turn is ~0.4), so small costs keep one
2439+
// decimal, and anything under 0.05 floors to "<0.1" rather than rounding to "0" — a run that cost
2440+
// something must never read as free. Returns the FIXED string: wrapping it in a unary + to drop
2441+
// trailing zeros silently defeated both of those rules. Identical to the dashboard's rate().
24332442
function creditCost(micros){
24342443
const c = toCredits(micros);
24352444
if (c <= 0) { return '0'; }
24362445
if (c >= 10) { return Math.round(c).toLocaleString(); }
2437-
// Fixed string, not +String(...): the unary + dropped the decimal (7.0 -> "7") and collapsed
2438-
// anything under 0.05 to "0" — a run that cost something reading as free. Same fix as the
2439-
// dashboard's rate(), kept identical so the two surfaces render a cost the same way.
24402446
return c < 0.05 ? '<0.1' : c.toFixed(1);
24412447
}
24422448

2443-
// Response action bar: (Retry) (Copy) (Helpful) (Unhelpful) … model · $cost · $left.
2449+
// Response action bar: (Retry) (Copy) (Helpful) (Unhelpful) … model · cost · balance, in credits.
24442450
function addAgentDone(reason, edits, credits, maxSteps, costMicros){
24452451
closeGroup(); // the run is over — finalize any open activity group
24462452
const bar = document.createElement('div'); bar.className = 'agentbar';
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Unit tests for the response bar's credit formatting — run: node test/creditFormat.test.js
3+
*
4+
* Like narrativeUi.test.js and shHighlight.test.js, the functions are EXTRACTED from the shipped
5+
* chat.html, so these exercise the real code rather than a copy that can drift from it.
6+
*
7+
* Two directions are load-bearing, and both are ways of lying about money:
8+
* 1. A run that cost something must never render as "0" — sub-credit turns are the common case on
9+
* cheap models, so they floor rather than round away.
10+
* 2. A balance must never render negative. An overage can push it below zero, and "-2 left" is a
11+
* worse thing to show someone than "0 left" (the dollar formatter this replaced clamped too).
12+
*--------------------------------------------------------------------------------------------*/
13+
// @ts-check
14+
'use strict';
15+
16+
const assert = require('assert');
17+
const fs = require('fs');
18+
const path = require('path');
19+
20+
const html = fs.readFileSync(path.join(__dirname, '..', 'media', 'chat.html'), 'utf8');
21+
22+
// Same slicing convention as narrativeUi.test.js: functions sit at 2 spaces and close with " }".
23+
function extract(name) {
24+
const start = html.indexOf('function ' + name + '(');
25+
assert.ok(start >= 0, 'chat.html no longer defines ' + name + '()');
26+
const end = html.indexOf('\n }', start);
27+
assert.ok(end >= 0, 'no closing brace found for ' + name + '()');
28+
return html.slice(start, end + 4);
29+
}
30+
31+
// MICROS_PER_CREDIT is a const in the same scope — pull it from the source so the test can never
32+
// disagree with the shipped conversion rate.
33+
const rateMatch = /const MICROS_PER_CREDIT = (\d+);/.exec(html);
34+
assert.ok(rateMatch, 'chat.html no longer defines MICROS_PER_CREDIT');
35+
assert.strictEqual(rateMatch[1], '10000', '1 credit must stay $0.01 — the website converts identically');
36+
37+
const sandbox = /** @type {any} */ ({});
38+
new Function(
39+
'const MICROS_PER_CREDIT = ' + rateMatch[1] + ';\n'
40+
+ extract('toCredits') + '\n' + extract('creditBalance') + '\n' + extract('creditCost') + '\n'
41+
+ 'this.toCredits = toCredits; this.creditBalance = creditBalance; this.creditCost = creditCost;'
42+
).call(sandbox);
43+
44+
const { toCredits, creditBalance, creditCost } = sandbox;
45+
46+
let n = 0;
47+
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }
48+
49+
const usd = (d) => Math.round(d * 1e6); // dollars → retail micro-$, the unit the gateway sends
50+
51+
// ---- 1. a cost must never read as free --------------------------------------------------------
52+
53+
test('COST: a sub-credit run floors to "<0.1" instead of rounding to zero', () => {
54+
// The cheap-model case, and the whole reason this is not a plain round(): a gpt-oss turn is ~0.4
55+
// credits and a very cheap one is far less. "0" would tell the user the run was free.
56+
assert.strictEqual(creditCost(usd(0.0002)), '<0.1');
57+
assert.strictEqual(creditCost(usd(0.0004)), '<0.1');
58+
assert.strictEqual(creditCost(usd(0.0036)), '0.4'); // the real gpt-oss per-turn figure
59+
});
60+
61+
test('COST: keeps one decimal below 10, whole credits above', () => {
62+
assert.strictEqual(creditCost(usd(0.07)), '7.0', 'the trailing .0 must survive');
63+
assert.strictEqual(creditCost(usd(0.0767)), '7.7');
64+
assert.strictEqual(creditCost(usd(0.46)), '46');
65+
assert.strictEqual(creditCost(usd(0.5117)), '51');
66+
});
67+
68+
test('COST: a genuinely zero cost is "0", not "<0.1"', () => {
69+
assert.strictEqual(creditCost(0), '0');
70+
assert.strictEqual(creditCost(-1), '0', 'a negative cost is nonsense — show nothing owed');
71+
});
72+
73+
// ---- 2. a balance must never read negative ----------------------------------------------------
74+
75+
test('BALANCE: whole credits, with separators', () => {
76+
assert.strictEqual(creditBalance(usd(12.79)), '1,279'); // the figure the dashboard shows
77+
assert.strictEqual(creditBalance(usd(100)), '10,000'); // an Ultra month
78+
assert.strictEqual(creditBalance(usd(0.004)), '0');
79+
});
80+
81+
test('BALANCE: an overage clamps to 0 rather than showing a negative', () => {
82+
// Going over budget makes remaining negative in the ledger. "-2 left" is worse than "0 left".
83+
assert.strictEqual(creditBalance(usd(-0.02)), '0');
84+
assert.strictEqual(creditBalance(usd(-5)), '0');
85+
assert.strictEqual(creditBalance(-1), '0');
86+
});
87+
88+
// ---- 3. junk in, something sane out ------------------------------------------------------------
89+
90+
test('ROBUST: nullish and non-numeric input never render NaN', () => {
91+
for (const junk of [null, undefined, '', 'abc', {}, []]) {
92+
assert.strictEqual(creditBalance(junk), '0', 'balance from ' + JSON.stringify(junk));
93+
assert.strictEqual(creditCost(junk), '0', 'cost from ' + JSON.stringify(junk));
94+
}
95+
assert.strictEqual(toCredits(Infinity), 0, 'a non-finite figure must collapse to 0, not Infinity');
96+
});
97+
98+
test('CONVERSION: $1 is 100 credits, in both directions of the bar', () => {
99+
assert.strictEqual(toCredits(usd(1)), 100);
100+
assert.strictEqual(creditBalance(usd(1)), '100');
101+
assert.strictEqual(creditCost(usd(1)), '100');
102+
});
103+
104+
console.log('\ncreditFormat (chat.html): ' + n + ' tests passed.');

0 commit comments

Comments
 (0)