@genesislcap/ai-assistant 15.7.0 → 15.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-assistant.api.json +32 -2
- package/dist/ai-assistant.d.ts +42 -14
- package/dist/custom-elements.json +55 -42
- package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
- package/dist/dts/main/main.d.ts +42 -14
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.styles.d.ts.map +1 -1
- package/dist/dts/utils/banked-usage-baselines.d.ts +51 -0
- package/dist/dts/utils/banked-usage-baselines.d.ts.map +1 -0
- package/dist/esm/components/settings-modal/settings-modal.styles.js +63 -18
- package/dist/esm/components/settings-modal/settings-modal.template.js +36 -7
- package/dist/esm/main/cost-session-banking.test.js +238 -8
- package/dist/esm/main/main.js +127 -37
- package/dist/esm/main/main.styles.js +26 -1
- package/dist/esm/main/main.styles.test.js +13 -0
- package/dist/esm/utils/banked-usage-baselines.js +84 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +17 -17
- package/src/components/settings-modal/settings-modal.styles.ts +63 -18
- package/src/components/settings-modal/settings-modal.template.ts +38 -7
- package/src/main/cost-session-banking.test.ts +283 -11
- package/src/main/main.styles.test.ts +27 -0
- package/src/main/main.styles.ts +26 -1
- package/src/main/main.ts +128 -37
- package/src/utils/banked-usage-baselines.ts +90 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { AggregateUsage } from '@genesislcap/foundation-ai';
|
|
2
|
+
/**
|
|
3
|
+
* The baseline resolved for `key`, or `undefined` when this session has not resolved one yet.
|
|
4
|
+
*
|
|
5
|
+
* `undefined` rather than an empty usage: "nothing banked" is a legitimate resolved value (a
|
|
6
|
+
* project's first ever session), and it must not be mistaken for "not resolved", or every write
|
|
7
|
+
* would re-derive and hit the double-count above.
|
|
8
|
+
*
|
|
9
|
+
* Returns a copy — see `setBankedBaseline` for why both ends of this map are cloned.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export declare function getBankedBaseline(key: string): AggregateUsage | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Record the baseline for `key`, replacing any previous one.
|
|
16
|
+
*
|
|
17
|
+
* Stores a copy, and `getBankedBaseline` hands back a copy, so nothing outside this module can
|
|
18
|
+
* reach the object the map holds. Both ends have to clone, because a baseline is aliased at both:
|
|
19
|
+
* `resolveBankedUsage` returns a *reference into a ledger row* (`row.banked` or `row.usage`),
|
|
20
|
+
* which is what gets stored here, and the caller then assigns what it reads back onto the NEXT
|
|
21
|
+
* row's `banked` field. Uncloned, one object is reachable from page-global state, two rows, and —
|
|
22
|
+
* since the row list is public and gets handed to a host's `saveCostHistory` — from host code.
|
|
23
|
+
* Whoever mutated it would silently rewrite this project's baseline for the rest of the page.
|
|
24
|
+
*
|
|
25
|
+
* A shallow spread is a complete copy: `AggregateUsage` is five flat numbers, no nested state.
|
|
26
|
+
*
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
export declare function setBankedBaseline(key: string, usage: AggregateUsage): void;
|
|
30
|
+
/**
|
|
31
|
+
* Drop the baseline for `key`, so the next write re-derives it from the ledger rows.
|
|
32
|
+
*
|
|
33
|
+
* For when the pairing is broken deliberately at the *row* end — the row this baseline came from
|
|
34
|
+
* has been deleted, or the whole ledger cleared — and for a session teardown, where the store
|
|
35
|
+
* (and with it the transcript) is disposed and a later return re-restores from scratch.
|
|
36
|
+
*
|
|
37
|
+
* NOT for a mid-session reload of the rows: those rows already include this session's own
|
|
38
|
+
* upserts, so re-deriving from them folds the live transcript into its own baseline.
|
|
39
|
+
*
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
export declare function deleteBankedBaseline(key: string): void;
|
|
43
|
+
/**
|
|
44
|
+
* Forget every baseline — the page-load boundary, which production gets for free from module
|
|
45
|
+
* state dying with the realm. Exists so tests can model separate page loads (and so one test's
|
|
46
|
+
* session key cannot leak a baseline into the next).
|
|
47
|
+
*
|
|
48
|
+
* @internal
|
|
49
|
+
*/
|
|
50
|
+
export declare function resetBankedBaselines(): void;
|
|
51
|
+
//# sourceMappingURL=banked-usage-baselines.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"banked-usage-baselines.d.ts","sourceRoot":"","sources":["../../../src/utils/banked-usage-baselines.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AA6BjE;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAGzE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,cAAc,GAAG,IAAI,CAE1E;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAEtD;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C"}
|
|
@@ -304,9 +304,9 @@ export const settingsModalStyles = css `
|
|
|
304
304
|
}
|
|
305
305
|
|
|
306
306
|
/*
|
|
307
|
-
* Deliberately quieter than the live block: no pulsing badge and no accent, so the
|
|
308
|
-
*
|
|
309
|
-
*
|
|
307
|
+
* Deliberately quieter than the live block: no pulsing badge and no accent, so the eye lands
|
|
308
|
+
* on what is being built now — which is why that block leads the tab and this one follows,
|
|
309
|
+
* under the history heading whose rows it totals.
|
|
310
310
|
*/
|
|
311
311
|
.settings-lifetime-usage-top {
|
|
312
312
|
display: flex;
|
|
@@ -489,7 +489,14 @@ export const settingsModalStyles = css `
|
|
|
489
489
|
|
|
490
490
|
.settings-token-bucket {
|
|
491
491
|
display: flex;
|
|
492
|
-
|
|
492
|
+
|
|
493
|
+
/*
|
|
494
|
+
* min-content (not 0) so a bucket cannot be shrunk narrower than its own unbreakable label.
|
|
495
|
+
* With the label set to nowrap, a zero minimum would overflow it into the next column instead
|
|
496
|
+
* of wrapping — so the container's flex-wrap takes over and moves a bucket to a second row,
|
|
497
|
+
* which is the degradation we want when the modal is dragged narrow.
|
|
498
|
+
*/
|
|
499
|
+
min-width: min-content;
|
|
493
500
|
flex-direction: column;
|
|
494
501
|
gap: 2px;
|
|
495
502
|
}
|
|
@@ -500,6 +507,15 @@ export const settingsModalStyles = css `
|
|
|
500
507
|
text-transform: uppercase;
|
|
501
508
|
letter-spacing: 0.06em;
|
|
502
509
|
color: var(--neutral-foreground-hint);
|
|
510
|
+
|
|
511
|
+
/*
|
|
512
|
+
* One line, always. The rate marker is a separate inline box after collapsible whitespace, so
|
|
513
|
+
* a wrappable label gives it a break opportunity of its own — in a narrow column it orphaned
|
|
514
|
+
* onto line 2, which also pushed that bucket's value a line below the other three and broke
|
|
515
|
+
* the row of figures. nowrap removes every break opportunity in the label, the space before
|
|
516
|
+
* the marker included, so no glue character is needed.
|
|
517
|
+
*/
|
|
518
|
+
white-space: nowrap;
|
|
503
519
|
}
|
|
504
520
|
|
|
505
521
|
.settings-token-bucket-value {
|
|
@@ -509,13 +525,24 @@ export const settingsModalStyles = css `
|
|
|
509
525
|
}
|
|
510
526
|
|
|
511
527
|
/*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
528
|
+
* The price-direction marker on a cache bucket's label — down for reads (a fraction of the
|
|
529
|
+
* uncached input rate), up for writes (a premium over it). Three tiers, so a bucket with no
|
|
530
|
+
* marker is the reference rate rather than an unlabelled fourth case.
|
|
531
|
+
*
|
|
532
|
+
* Carried by the label rather than the value, which is what this used to do: cache reads are
|
|
533
|
+
* usually the largest bucket by a wide margin in an agentic session, so the value was muted to
|
|
534
|
+
* stop the biggest number reading as the dominant cost driver. That priced the treatment to the
|
|
535
|
+
* wrong thing — a dimmed 0 (every Gemini cache write) looked disabled next to a normal 0, and a
|
|
536
|
+
* rate is a property of the bucket, not of this turn's figure.
|
|
537
|
+
*
|
|
538
|
+
* Sized down and nudged up rather than tinted: the label is already the hint colour, so a
|
|
539
|
+
* dimmer marker would vanish and a brighter one would out-shout the word it qualifies.
|
|
515
540
|
*/
|
|
516
|
-
.settings-token-bucket
|
|
517
|
-
font-
|
|
518
|
-
|
|
541
|
+
.settings-token-bucket-rate {
|
|
542
|
+
font-size: 8px;
|
|
543
|
+
line-height: 1;
|
|
544
|
+
vertical-align: 0.15em;
|
|
545
|
+
letter-spacing: 0;
|
|
519
546
|
}
|
|
520
547
|
|
|
521
548
|
/*
|
|
@@ -537,9 +564,15 @@ export const settingsModalStyles = css `
|
|
|
537
564
|
margin-bottom: 0;
|
|
538
565
|
}
|
|
539
566
|
|
|
540
|
-
/*
|
|
567
|
+
/*
|
|
568
|
+
* Basis auto, not 0: a zero basis splits the row into four equal columns, so CACHE WRITE is
|
|
569
|
+
* squeezed exactly as hard as INPUT despite being twice the label. Sizing from content gives the
|
|
570
|
+
* long labels the room their text needs and lets the short ones concede it, which is what makes
|
|
571
|
+
* white-space: nowrap above safe — every column starts at least as wide as its own content,
|
|
572
|
+
* then shares the slack. Still grows and shrinks, so the four stay flush edge to edge.
|
|
573
|
+
*/
|
|
541
574
|
.settings-usage-row .settings-token-bucket {
|
|
542
|
-
flex: 1 1
|
|
575
|
+
flex: 1 1 auto;
|
|
543
576
|
}
|
|
544
577
|
|
|
545
578
|
.settings-model-tags {
|
|
@@ -579,6 +612,16 @@ export const settingsModalStyles = css `
|
|
|
579
612
|
margin: calc(var(--design-unit) * 4px) 0 calc(var(--design-unit) * 2px);
|
|
580
613
|
}
|
|
581
614
|
|
|
615
|
+
/*
|
|
616
|
+
* The heading labels the lifetime card as well as the rows, so the card gives up its own top
|
|
617
|
+
* margin and the gap becomes the heading's alone. Adjacent siblings collapse to the LARGER of
|
|
618
|
+
* the two margins, so leaving both would space the caps 3 units off the block they label while
|
|
619
|
+
* the rows below sit at 2 — reading as though the heading belonged to neither.
|
|
620
|
+
*/
|
|
621
|
+
.settings-cost-history-title + .settings-lifetime-usage {
|
|
622
|
+
margin-top: 0;
|
|
623
|
+
}
|
|
624
|
+
|
|
582
625
|
.settings-cost-history {
|
|
583
626
|
display: flex;
|
|
584
627
|
flex-direction: column;
|
|
@@ -615,14 +658,16 @@ export const settingsModalStyles = css `
|
|
|
615
658
|
}
|
|
616
659
|
|
|
617
660
|
/*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
* treatment as .settings-modal-tab-panel[hidden]
|
|
661
|
+
* All of these carry a ?hidden binding, and all declare display: flex — which silently beat the
|
|
662
|
+
* UA [hidden] { display: none } rule, because author-origin declarations outrank user-agent
|
|
663
|
+
* origin whatever the specificity. Without this, showTokens: false still rendered the full
|
|
664
|
+
* breakdown in the lifetime block, the live block and every history row, and an empty row list
|
|
665
|
+
* still occupied its padding under the lifetime total. Sibling elements that hide correctly do
|
|
666
|
+
* so only by declaring no display at all. Same treatment as .settings-modal-tab-panel[hidden]
|
|
667
|
+
* near the top of this file.
|
|
624
668
|
*/
|
|
625
669
|
.settings-usage-row-tokens[hidden],
|
|
670
|
+
.settings-cost-history[hidden],
|
|
626
671
|
.settings-cost-history-row-content[hidden] {
|
|
627
672
|
display: none !important;
|
|
628
673
|
}
|
|
@@ -204,6 +204,12 @@ const modelTagTemplate = html `
|
|
|
204
204
|
* this puts output between the three buckets that partition the prompt, so their relationship is
|
|
205
205
|
* carried by the tooltips rather than by adjacency.
|
|
206
206
|
*
|
|
207
|
+
* The two cache buckets carry a direction marker on their LABEL, not their value: below the input
|
|
208
|
+
* rate for reads, above it for writes. On the label because the rate is a fixed property of the
|
|
209
|
+
* bucket, whereas the value changes every turn — and because styling the values instead means a
|
|
210
|
+
* bucket reading 0 (every Gemini cache write, which has no write bucket to bill) renders in a
|
|
211
|
+
* dimmed treatment that looks disabled beside a normal 0. All four values stay identical.
|
|
212
|
+
*
|
|
207
213
|
* `partPrefix` keeps the shadow-part names caller-specific (`session-tokens-input`,
|
|
208
214
|
* `lifetime-tokens-input`, …) so a host can style one context without hitting the others.
|
|
209
215
|
*/
|
|
@@ -240,12 +246,17 @@ const tokenBreakdownTemplate = (usageOf, partPrefix) => html `
|
|
|
240
246
|
:innerHTML="${(x) => formatCompactTokenCountSafe(usageOf(x).outputTokens)}"
|
|
241
247
|
></span>
|
|
242
248
|
</div>
|
|
243
|
-
<div class="settings-token-bucket
|
|
249
|
+
<div class="settings-token-bucket" part="${partPrefix}-tokens-cache-read">
|
|
244
250
|
<span
|
|
245
251
|
class="settings-token-bucket-label"
|
|
246
252
|
title="Prompt tokens served from the provider cache — billed at a fraction of the uncached rate"
|
|
247
253
|
>
|
|
248
254
|
Cache read
|
|
255
|
+
<!--
|
|
256
|
+
aria-hidden: the marker is a restatement of the tooltip this label already carries, and
|
|
257
|
+
a screen reader announcing "black down-pointing small triangle" mid-heading is noise.
|
|
258
|
+
-->
|
|
259
|
+
<span class="settings-token-bucket-rate" aria-hidden="true">▾</span>
|
|
249
260
|
</span>
|
|
250
261
|
<span
|
|
251
262
|
class="settings-token-bucket-value"
|
|
@@ -258,6 +269,7 @@ const tokenBreakdownTemplate = (usageOf, partPrefix) => html `
|
|
|
258
269
|
title="Prompt tokens written to the provider cache — billed at a premium over the uncached rate"
|
|
259
270
|
>
|
|
260
271
|
Cache write
|
|
272
|
+
<span class="settings-token-bucket-rate" aria-hidden="true">▴</span>
|
|
261
273
|
</span>
|
|
262
274
|
<span
|
|
263
275
|
class="settings-token-bucket-value"
|
|
@@ -270,9 +282,14 @@ const tokenBreakdownTemplate = (usageOf, partPrefix) => html `
|
|
|
270
282
|
/**
|
|
271
283
|
* Lifetime usage across every project in the history scope.
|
|
272
284
|
*
|
|
273
|
-
* Sits
|
|
274
|
-
*
|
|
275
|
-
*
|
|
285
|
+
* Sits under the "Build history" heading, above the rows it totals — the live block leads the tab
|
|
286
|
+
* instead, because the project open now is what a user came to check. Being inside the history
|
|
287
|
+
* grouping is also what identifies this figure: it is the sum of the rows below it, not a rival
|
|
288
|
+
* reading of the block above it.
|
|
289
|
+
*
|
|
290
|
+
* It must not sit inside the live block, which is where it began life as a lone "Total build
|
|
291
|
+
* cost": the live badge and project name directly above it implied the total belonged to the open
|
|
292
|
+
* project.
|
|
276
293
|
*/
|
|
277
294
|
const lifetimeUsageTemplate = () => html `
|
|
278
295
|
${when((x) => x.settingsCostLifetimeVisible, html `
|
|
@@ -509,7 +526,6 @@ export const SettingsModalTemplate = (designSystemPrefix) => {
|
|
|
509
526
|
part="cost-tab-panel"
|
|
510
527
|
>
|
|
511
528
|
<div class="settings-modal-body" part="settings-modal-body">
|
|
512
|
-
${lifetimeUsageTemplate()}
|
|
513
529
|
${sessionCostSummaryTemplate()}
|
|
514
530
|
<div
|
|
515
531
|
class="settings-cost-current"
|
|
@@ -518,9 +534,15 @@ export const SettingsModalTemplate = (designSystemPrefix) => {
|
|
|
518
534
|
>
|
|
519
535
|
<slot name="settings-cost-current" ${slotted('settingsCostCurrentSlotted')}></slot>
|
|
520
536
|
</div>
|
|
521
|
-
|
|
537
|
+
<!--
|
|
538
|
+
The heading covers everything below it, lifetime total included — that total IS the sum of
|
|
539
|
+
the history rows, so it reads as their summary rather than a rival figure. Hence the
|
|
540
|
+
heading's own visibility gate, which is broader than the rows'.
|
|
541
|
+
-->
|
|
542
|
+
<p class="settings-cost-history-title" ?hidden=${(x) => !x.settingsCostHistoryHeadingVisible}>
|
|
522
543
|
Build history
|
|
523
544
|
</p>
|
|
545
|
+
${lifetimeUsageTemplate()}
|
|
524
546
|
<div
|
|
525
547
|
class="settings-cost-history"
|
|
526
548
|
part="settings-cost-history"
|
|
@@ -574,7 +596,14 @@ export const SettingsModalTemplate = (designSystemPrefix) => {
|
|
|
574
596
|
${when((x) => !!x.resolvedCostHistory.summaryHint, html `
|
|
575
597
|
${(x) => x.resolvedCostHistory.summaryHint}
|
|
576
598
|
`)}Costs
|
|
577
|
-
are estimated from token usage at list
|
|
599
|
+
are estimated from token usage at list
|
|
600
|
+
price.${when(
|
|
601
|
+
// Only where the markers actually render. Every place they appear is gated on
|
|
602
|
+
// showTokens, so with cost figures but no token buckets this sentence would
|
|
603
|
+
// define two glyphs that are nowhere on screen.
|
|
604
|
+
(x) => x.resolvedCostHistory.showTokens, html `
|
|
605
|
+
Cache reads bill below the input rate (▾) and cache writes above it (▴).
|
|
606
|
+
`)}
|
|
578
607
|
</span>
|
|
579
608
|
`)}
|
|
580
609
|
<${buttonTag}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
2
|
+
import { getBankedBaseline, resetBankedBaselines, setBankedBaseline, } from '../utils/banked-usage-baselines';
|
|
2
3
|
import { clearCostSessionHistory, loadCostSessionHistory, } from '../utils/cost-session-history';
|
|
3
4
|
import { emptyUsage } from '../utils/sum-usage';
|
|
4
5
|
import { FoundationAiAssistant } from './main';
|
|
@@ -28,9 +29,17 @@ const usage = (costUsd, outputTokens = 100) => (Object.assign(Object.assign({},
|
|
|
28
29
|
/**
|
|
29
30
|
* An unconnected element with just enough session state wired for `finalizeCostSession`:
|
|
30
31
|
* the live usage it projects, the transcript it titles from, and the key it upserts against.
|
|
32
|
+
*
|
|
33
|
+
* Banked baselines are module state keyed by session (so remounts share one), which means they
|
|
34
|
+
* outlive an element. Cleared by default, because every suite but the remount ones is modelling
|
|
35
|
+
* a *fresh page load* — and because leaving them would leak one suite's baseline into the next
|
|
36
|
+
* under the shared `PROJECT` key. Pass `remount: true` to model a new element joining a session
|
|
37
|
+
* that has already banked, which is the case this file exists to pin.
|
|
31
38
|
*/
|
|
32
39
|
function element(options) {
|
|
33
40
|
var _a, _b, _c;
|
|
41
|
+
if (!options.remount)
|
|
42
|
+
resetBankedBaselines();
|
|
34
43
|
const el = document.createElement('foundation-ai-assistant');
|
|
35
44
|
const messages = [{ role: 'user', content: 'build me a blotter' }];
|
|
36
45
|
const internals = el;
|
|
@@ -267,10 +276,10 @@ Suite('an unreadable ledger blocks writes instead of replacing it with what we c
|
|
|
267
276
|
clearCostSessionHistory(scope);
|
|
268
277
|
});
|
|
269
278
|
Suite('deleting a provider row whose id differs from its projectKey still drops the memo', () => {
|
|
270
|
-
//
|
|
271
|
-
// any id, so
|
|
272
|
-
// the deleted total. Reachable when a provider load lands AFTER a finalize has
|
|
273
|
-
// upsert would otherwise have rewritten the row's id to the projectKey.
|
|
279
|
+
// Baselines are keyed by projectKey; the delete action passes a row id. A host provider can
|
|
280
|
+
// assign any id, so deleting by the wrong namespace left the baseline in place and the next
|
|
281
|
+
// turn resurrected the deleted total. Reachable when a provider load lands AFTER a finalize has
|
|
282
|
+
// banked: our own upsert would otherwise have rewritten the row's id to the projectKey.
|
|
274
283
|
const scope = 'ledger-remove-id';
|
|
275
284
|
clearCostSessionHistory(scope);
|
|
276
285
|
const el = element({
|
|
@@ -279,16 +288,221 @@ Suite('deleting a provider row whose id differs from its projectKey still drops
|
|
|
279
288
|
rows: [row({ id: 'host-assigned-uuid', projectKey: PROJECT, usage: usage(10) })],
|
|
280
289
|
persistenceActive: false,
|
|
281
290
|
});
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
291
|
+
// Baseline already resolved for this project, as it would be after a turn — set directly
|
|
292
|
+
// rather than by finalizing, because our own upsert would rewrite the row's id to the
|
|
293
|
+
// projectKey and the id mismatch under test would vanish with it.
|
|
294
|
+
setBankedBaseline(PROJECT, usage(10));
|
|
286
295
|
el.removeCostSessionFromHistory('host-assigned-uuid');
|
|
287
296
|
assert.is(el.costSessionHistory.length, 0);
|
|
288
297
|
el.finalizeCostSession();
|
|
289
298
|
assert.is(el.costSessionHistory[0].usage.costUsd, 2, 'deleted total must not come back');
|
|
290
299
|
clearCostSessionHistory(scope);
|
|
291
300
|
});
|
|
301
|
+
Suite('a remount mid-session does not re-bank what the row already holds', () => {
|
|
302
|
+
var _a;
|
|
303
|
+
// The reported regression (GENC-1475). Reproduces the field report exactly: with persistence
|
|
304
|
+
// off, the host remounted the assistant part-way through a session (a layout re-render — the
|
|
305
|
+
// debug log showed seven connects in one session) and the LIFETIME figure jumped by the whole
|
|
306
|
+
// session-to-date while the live figure kept climbing normally.
|
|
307
|
+
//
|
|
308
|
+
// The new element joins a live session: same key, same transcript, and rows that already
|
|
309
|
+
// contain the previous instance's upsert. An element-scoped baseline re-derived here and, with
|
|
310
|
+
// no restore to defer to, banked `row.usage` — which already included this very transcript.
|
|
311
|
+
const scope = 'bank-remount';
|
|
312
|
+
clearCostSessionHistory(scope);
|
|
313
|
+
const first = element({
|
|
314
|
+
scope,
|
|
315
|
+
live: usage(6),
|
|
316
|
+
rows: [row({ usage: usage(1), banked: usage(1) })],
|
|
317
|
+
persistenceActive: false,
|
|
318
|
+
});
|
|
319
|
+
first.finalizeCostSession();
|
|
320
|
+
assert.is(first.costSessionHistory[0].usage.costUsd, 7, '1 banked + 6 live');
|
|
321
|
+
// Remount: a brand new element wired to the same session, handed the rows as they now stand.
|
|
322
|
+
const second = element({
|
|
323
|
+
scope,
|
|
324
|
+
live: usage(6),
|
|
325
|
+
rows: first.costSessionHistory,
|
|
326
|
+
persistenceActive: false,
|
|
327
|
+
remount: true,
|
|
328
|
+
});
|
|
329
|
+
second.finalizeCostSession();
|
|
330
|
+
assert.is(second.costSessionHistory[0].usage.costUsd, 7, 'remount must not inflate the row');
|
|
331
|
+
assert.is((_a = second.costSessionHistory[0].banked) === null || _a === void 0 ? void 0 : _a.costUsd, 1, 'baseline unchanged by the remount');
|
|
332
|
+
// And the session keeps accruing normally afterwards, on top of the SAME baseline.
|
|
333
|
+
const third = element({
|
|
334
|
+
scope,
|
|
335
|
+
live: usage(9),
|
|
336
|
+
rows: second.costSessionHistory,
|
|
337
|
+
persistenceActive: false,
|
|
338
|
+
remount: true,
|
|
339
|
+
});
|
|
340
|
+
third.finalizeCostSession();
|
|
341
|
+
assert.is(third.costSessionHistory[0].usage.costUsd, 10, '1 banked + 9 live');
|
|
342
|
+
clearCostSessionHistory(scope);
|
|
343
|
+
});
|
|
344
|
+
Suite('a page reload still absorbs the prior total after a remount banked nothing new', () => {
|
|
345
|
+
// The other half of the pairing: dropping the baseline on remount is wrong, but keeping it
|
|
346
|
+
// across a *page load* would be too — the transcript starts empty there (persistence off), so
|
|
347
|
+
// the row's whole total must be banked. `resetBankedBaselines` stands in for the realm dying.
|
|
348
|
+
const scope = 'bank-reload';
|
|
349
|
+
clearCostSessionHistory(scope);
|
|
350
|
+
const before = element({
|
|
351
|
+
scope,
|
|
352
|
+
live: usage(6),
|
|
353
|
+
rows: [row({ usage: usage(1), banked: usage(1) })],
|
|
354
|
+
persistenceActive: false,
|
|
355
|
+
});
|
|
356
|
+
before.finalizeCostSession();
|
|
357
|
+
assert.is(before.costSessionHistory[0].usage.costUsd, 7);
|
|
358
|
+
const afterReload = element({
|
|
359
|
+
scope,
|
|
360
|
+
live: usage(2),
|
|
361
|
+
rows: before.costSessionHistory,
|
|
362
|
+
persistenceActive: false,
|
|
363
|
+
});
|
|
364
|
+
afterReload.finalizeCostSession();
|
|
365
|
+
assert.is(afterReload.costSessionHistory[0].usage.costUsd, 9, 'prior 7 banked, this load adds 2');
|
|
366
|
+
clearCostSessionHistory(scope);
|
|
367
|
+
});
|
|
368
|
+
Suite('clearing the transcript re-baselines instead of discarding spend', () => {
|
|
369
|
+
var _a;
|
|
370
|
+
// Clear empties the transcript, so the row's total now predates what is on screen and has to be
|
|
371
|
+
// banked wholesale. Merely dropping the baseline would re-derive through `persistenceActive`,
|
|
372
|
+
// which with persistence ON carries the row's OLD banked figure forward and silently loses
|
|
373
|
+
// every turn between that baseline and the clear.
|
|
374
|
+
const scope = 'bank-clear-session';
|
|
375
|
+
clearCostSessionHistory(scope);
|
|
376
|
+
const el = element({
|
|
377
|
+
scope,
|
|
378
|
+
live: usage(6),
|
|
379
|
+
rows: [row({ usage: usage(10), banked: usage(4) })],
|
|
380
|
+
persistenceActive: true,
|
|
381
|
+
});
|
|
382
|
+
el.finalizeCostSession();
|
|
383
|
+
assert.is(el.costSessionHistory[0].usage.costUsd, 10);
|
|
384
|
+
// `clearSession` itself tears down and rebuilds the driver, which an unconnected element has
|
|
385
|
+
// none of — so drive the one step under test. `clearSession` reads the live figure before
|
|
386
|
+
// `resetSession` zeroes it and passes it in; the 6 here is that capture.
|
|
387
|
+
const priv = el;
|
|
388
|
+
priv.rebaselineOnClearedTranscript(PROJECT, usage(6));
|
|
389
|
+
// A fresh transcript worth 3, on top of the whole 10 the project had accrued.
|
|
390
|
+
priv._sessionRef.store.aiAssistant.sessionUsage = usage(3);
|
|
391
|
+
el.finalizeCostSession();
|
|
392
|
+
assert.is(el.costSessionHistory[0].usage.costUsd, 13, 'pre-clear spend kept, new turn added');
|
|
393
|
+
assert.is((_a = el.costSessionHistory[0].banked) === null || _a === void 0 ? void 0 : _a.costUsd, 10, 'whole pre-clear total banked');
|
|
394
|
+
clearCostSessionHistory(scope);
|
|
395
|
+
});
|
|
396
|
+
Suite('clearing while the ledger is still loading must not pin a baseline', () => {
|
|
397
|
+
var _a;
|
|
398
|
+
// Review case. A stamped baseline is sticky — `bankedUsageFor` short-circuits on any stored
|
|
399
|
+
// entry and a zero usage is a *resolved* value there. On the provider path the row list is empty
|
|
400
|
+
// and `_costHistoryLoaded` false while the read is in flight, so a clear landing in that window
|
|
401
|
+
// used to pin a baseline derived from no rows at all. The load arriving afterwards with the
|
|
402
|
+
// project's real total was then never consulted, and the next write replaced its row with
|
|
403
|
+
// `banked: 0` — the project's whole history gone.
|
|
404
|
+
const scope = 'bank-clear-unloaded';
|
|
405
|
+
clearCostSessionHistory(scope);
|
|
406
|
+
const el = element({ scope, live: usage(5), loaded: false });
|
|
407
|
+
const priv = el;
|
|
408
|
+
// Clear arrives before the provider read lands: rows are empty and nothing is known yet.
|
|
409
|
+
priv.rebaselineOnClearedTranscript(PROJECT, usage(5));
|
|
410
|
+
assert.is(getBankedBaseline(PROJECT), undefined, 'nothing pinned from a ledger we cannot see');
|
|
411
|
+
// The read lands with this project's real history, and a turn follows.
|
|
412
|
+
el.costSessionHistory = [row({ usage: usage(42), banked: usage(40) })];
|
|
413
|
+
priv._costHistoryLoaded = true;
|
|
414
|
+
priv._sessionRef.store.aiAssistant.sessionUsage = usage(1);
|
|
415
|
+
el.finalizeCostSession();
|
|
416
|
+
assert.is(el.costSessionHistory[0].usage.costUsd, 43, "the project's 42 survives the race");
|
|
417
|
+
assert.is((_a = el.costSessionHistory[0].banked) === null || _a === void 0 ? void 0 : _a.costUsd, 42, 'baseline re-derived from real rows');
|
|
418
|
+
clearCostSessionHistory(scope);
|
|
419
|
+
});
|
|
420
|
+
Suite('clearing banks a transcript the row has not caught up with', () => {
|
|
421
|
+
var _a;
|
|
422
|
+
// Review case: without this, the suite passed identically against the OLD derivation
|
|
423
|
+
// (`existing?.usage`), because everywhere else the row already agreed with baseline + live.
|
|
424
|
+
//
|
|
425
|
+
// The formulas only diverge when the row is STALE relative to the transcript — which is exactly
|
|
426
|
+
// what the new one exists for, since `finalizeCostSession` skips its write while the ledger is
|
|
427
|
+
// unloaded and while the session has no usage. No finalize here, so the row lags by 3.
|
|
428
|
+
const scope = 'bank-clear-stale-row';
|
|
429
|
+
clearCostSessionHistory(scope);
|
|
430
|
+
const el = element({
|
|
431
|
+
scope,
|
|
432
|
+
live: usage(3),
|
|
433
|
+
rows: [row({ usage: usage(42), banked: usage(40) })],
|
|
434
|
+
persistenceActive: false,
|
|
435
|
+
});
|
|
436
|
+
const priv = el;
|
|
437
|
+
priv.rebaselineOnClearedTranscript(PROJECT, usage(3));
|
|
438
|
+
// 42 from the row plus the 3 the discarded transcript proved. The old row-read banked 42 flat.
|
|
439
|
+
assert.is((_a = getBankedBaseline(PROJECT)) === null || _a === void 0 ? void 0 : _a.costUsd, 45, 'the stale 3 is banked, not dropped');
|
|
440
|
+
priv._sessionRef.store.aiAssistant.sessionUsage = usage(1);
|
|
441
|
+
el.finalizeCostSession();
|
|
442
|
+
assert.is(el.costSessionHistory[0].usage.costUsd, 46, '45 banked + the new turn');
|
|
443
|
+
clearCostSessionHistory(scope);
|
|
444
|
+
});
|
|
445
|
+
Suite('a memoised baseline survives a clear during a reload instead of being dropped', () => {
|
|
446
|
+
var _a, _b;
|
|
447
|
+
// Review case. The guard is broader than the derivation needs: once a baseline is memoised the
|
|
448
|
+
// new figure is determined without any rows, so deleting it there throws away something correct.
|
|
449
|
+
// Concretely — row at usage 120 / banked 90, baseline memoised at 90, 30 on screen — dropping it
|
|
450
|
+
// made the next turn re-derive `row.banked` and rewrite the row BACKWARDS from 120 to 95.
|
|
451
|
+
const scope = 'bank-clear-memoised';
|
|
452
|
+
clearCostSessionHistory(scope);
|
|
453
|
+
const el = element({
|
|
454
|
+
scope,
|
|
455
|
+
live: usage(30),
|
|
456
|
+
rows: [row({ usage: usage(120), banked: usage(90) })],
|
|
457
|
+
persistenceActive: true,
|
|
458
|
+
});
|
|
459
|
+
el.finalizeCostSession();
|
|
460
|
+
assert.is((_a = getBankedBaseline(PROJECT)) === null || _a === void 0 ? void 0 : _a.costUsd, 90, 'baseline memoised off the restored row');
|
|
461
|
+
// A chatConfig rebind puts the ledger back in flight, and the clear lands inside that window.
|
|
462
|
+
const priv = el;
|
|
463
|
+
priv._costHistoryLoaded = false;
|
|
464
|
+
priv.rebaselineOnClearedTranscript(PROJECT, usage(30));
|
|
465
|
+
assert.is((_b = getBankedBaseline(PROJECT)) === null || _b === void 0 ? void 0 : _b.costUsd, 120, '90 kept and the discarded 30 added');
|
|
466
|
+
priv._costHistoryLoaded = true;
|
|
467
|
+
priv._sessionRef.store.aiAssistant.sessionUsage = usage(5);
|
|
468
|
+
el.finalizeCostSession();
|
|
469
|
+
assert.is(el.costSessionHistory[0].usage.costUsd, 125, 'row moves forward, never backwards');
|
|
470
|
+
clearCostSessionHistory(scope);
|
|
471
|
+
});
|
|
472
|
+
Suite('clearing with the ledger disabled pins nothing, even though it reads as loaded', () => {
|
|
473
|
+
// Review case. `reloadCostSessionHistory` sets `_costHistoryLoaded` TRUE over a deliberately
|
|
474
|
+
// empty list when `enabled` is false — "nothing will be loaded", not "rows are known" — so the
|
|
475
|
+
// flag alone let a clear pin a baseline from a ledger that was never read. A host enabling
|
|
476
|
+
// history afterwards (an async setting, a feature flag) then had its real row buried by it.
|
|
477
|
+
const scope = 'bank-clear-disabled';
|
|
478
|
+
clearCostSessionHistory(scope);
|
|
479
|
+
const el = element({ scope, live: usage(3) });
|
|
480
|
+
el.chatConfig = { costHistory: { enabled: false, scope } };
|
|
481
|
+
const priv = el;
|
|
482
|
+
assert.is(priv._costHistoryLoaded, true, 'reads as loaded despite nothing being known');
|
|
483
|
+
priv.rebaselineOnClearedTranscript(PROJECT, usage(3));
|
|
484
|
+
assert.is(getBankedBaseline(PROJECT), undefined, 'nothing pinned while the ledger is off');
|
|
485
|
+
// History switched on afterwards, and this project turns out to have real spend.
|
|
486
|
+
el.chatConfig = { costHistory: { enabled: true, scope, title: 'Trade Blotter' } };
|
|
487
|
+
el.costSessionHistory = [row({ usage: usage(50), banked: usage(50) })];
|
|
488
|
+
priv._costHistoryLoaded = true;
|
|
489
|
+
priv._sessionRef.store.aiAssistant.sessionUsage = usage(1);
|
|
490
|
+
el.finalizeCostSession();
|
|
491
|
+
assert.is(el.costSessionHistory[0].usage.costUsd, 51, "the project's 50 is not buried");
|
|
492
|
+
clearCostSessionHistory(scope);
|
|
493
|
+
});
|
|
494
|
+
Suite('a host slotting its own build history does not have to enable our ledger', () => {
|
|
495
|
+
// Review case. `enabled` governs the BUILT-IN ledger and defaults false, while the Usage tab
|
|
496
|
+
// shows for showCost/showTokens which default true. Once the section's ?hidden binding became
|
|
497
|
+
// effective, short-circuiting on `enabled` above the slot check hid a host's own markup — and
|
|
498
|
+
// the slot is the only thing in that container.
|
|
499
|
+
const el = element({ scope: 'slotted-history', live: usage(1) });
|
|
500
|
+
el.chatConfig = { costHistory: { enabled: false } };
|
|
501
|
+
assert.is(el.settingsCostHistorySectionVisible, false, 'nothing slotted, ledger off → hidden');
|
|
502
|
+
el.settingsCostHistorySlotted = [document.createElement('div')];
|
|
503
|
+
assert.is(el.settingsCostHistorySectionVisible, true, 'slotted content shows without `enabled`');
|
|
504
|
+
assert.is(el.settingsCostHistoryHeadingVisible, true, 'and the heading comes with it');
|
|
505
|
+
});
|
|
292
506
|
Suite('hides the lifetime section when no state key at all can produce a row', () => {
|
|
293
507
|
const el = element({ scope: 'ledger-nokey', live: usage(3) });
|
|
294
508
|
assert.is(el.settingsCostLifetimeVisible, true, 'visible with a key');
|
|
@@ -305,4 +519,20 @@ Suite('labels the live figures by whether the transcript is authoritative', () =
|
|
|
305
519
|
const without = element({ scope: 'bank-label', live: usage(1), persistenceActive: false });
|
|
306
520
|
assert.is(without.liveUsageIsProjectLifetime, false, 'not restored → this session only');
|
|
307
521
|
});
|
|
522
|
+
Suite('a stored baseline is isolated from the row and the caller it passed through', () => {
|
|
523
|
+
var _a, _b;
|
|
524
|
+
// A baseline arrives as a reference into a ledger row (`resolveBankedUsage` returns `row.banked`
|
|
525
|
+
// or `row.usage`) and is read back onto the NEXT row's `banked` field, so an uncloned map entry
|
|
526
|
+
// is reachable from page-global state, two rows, and any host handed the row list. Mutating
|
|
527
|
+
// either end must not move the stored figure, or a project's baseline is silently rewritten for
|
|
528
|
+
// the rest of the page.
|
|
529
|
+
resetBankedBaselines();
|
|
530
|
+
const source = usage(10);
|
|
531
|
+
setBankedBaseline(PROJECT, source);
|
|
532
|
+
source.costUsd = 999;
|
|
533
|
+
assert.is((_a = getBankedBaseline(PROJECT)) === null || _a === void 0 ? void 0 : _a.costUsd, 10, 'mutating the source cannot reach the map');
|
|
534
|
+
const readBack = getBankedBaseline(PROJECT);
|
|
535
|
+
readBack.costUsd = 777;
|
|
536
|
+
assert.is((_b = getBankedBaseline(PROJECT)) === null || _b === void 0 ? void 0 : _b.costUsd, 10, 'mutating a read copy cannot reach the map');
|
|
537
|
+
});
|
|
308
538
|
Suite.run();
|