@genesislcap/ai-assistant 15.4.1 → 15.5.0
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 +544 -82
- package/dist/ai-assistant.d.ts +324 -36
- package/dist/chat-driver.cjs +94 -22
- package/dist/chat-driver.cjs.map +3 -3
- package/dist/chat-driver.mjs +94 -22
- package/dist/chat-driver.mjs.map +3 -3
- package/dist/custom-elements.json +303 -36
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- 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 +9 -2
- package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
- package/dist/dts/index.d.ts +1 -0
- package/dist/dts/index.d.ts.map +1 -1
- package/dist/dts/main/cost-session-banking.test.d.ts +2 -0
- package/dist/dts/main/cost-session-banking.test.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +184 -24
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/provider/assistant-app-settings.d.ts +30 -5
- package/dist/dts/provider/assistant-app-settings.d.ts.map +1 -1
- package/dist/dts/state/ai-assistant-slice.d.ts +11 -8
- package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
- package/dist/dts/state/persistence/session-persistence-provider.d.ts +23 -0
- package/dist/dts/state/persistence/session-persistence-provider.d.ts.map +1 -1
- package/dist/dts/state/persistence/session-snapshot.d.ts.map +1 -1
- package/dist/dts/state/session-store.d.ts +1 -2
- package/dist/dts/state/session-store.d.ts.map +1 -1
- package/dist/dts/styles/settings-section.d.ts +29 -0
- package/dist/dts/styles/settings-section.d.ts.map +1 -0
- package/dist/dts/utils/cost-session-history.d.ts +103 -12
- package/dist/dts/utils/cost-session-history.d.ts.map +1 -1
- package/dist/dts/utils/resolve-cost-history-config.d.ts +9 -3
- package/dist/dts/utils/resolve-cost-history-config.d.ts.map +1 -1
- package/dist/dts/utils/sum-costs.d.ts.map +1 -1
- package/dist/dts/utils/sum-tokens.d.ts +8 -8
- package/dist/dts/utils/sum-tokens.d.ts.map +1 -1
- package/dist/dts/utils/sum-usage.d.ts +59 -0
- package/dist/dts/utils/sum-usage.d.ts.map +1 -0
- package/dist/dts/utils/sum-usage.test.d.ts +2 -0
- package/dist/dts/utils/sum-usage.test.d.ts.map +1 -0
- package/dist/esm/components/chat-driver/chat-driver.js +6 -0
- package/dist/esm/components/settings-modal/settings-modal.styles.js +237 -18
- package/dist/esm/components/settings-modal/settings-modal.template.js +229 -73
- package/dist/esm/index.js +1 -0
- package/dist/esm/main/cost-session-banking.test.js +308 -0
- package/dist/esm/main/main.js +424 -71
- package/dist/esm/state/ai-assistant-slice.js +11 -8
- package/dist/esm/state/ai-assistant-slice.test.js +12 -5
- package/dist/esm/state/debug-event-log.js +2 -2
- package/dist/esm/state/persistence/session-persistence.integration.test.js +5 -1
- package/dist/esm/state/persistence/session-persister.js +2 -2
- package/dist/esm/state/persistence/session-persister.test.js +10 -1
- package/dist/esm/state/persistence/session-snapshot.js +6 -2
- package/dist/esm/state/persistence/session-snapshot.test.js +4 -1
- package/dist/esm/state/persistence/stateful-restore.e2e.test.js +10 -1
- package/dist/esm/styles/settings-section.js +39 -0
- package/dist/esm/utils/cost-session-history.js +92 -15
- package/dist/esm/utils/cost-session-history.test.js +155 -13
- package/dist/esm/utils/resolve-cost-history-config.js +2 -1
- package/dist/esm/utils/sum-costs.js +2 -13
- package/dist/esm/utils/sum-tokens.js +10 -27
- package/dist/esm/utils/sum-tokens.test.js +1 -5
- package/dist/esm/utils/sum-usage.js +123 -0
- package/dist/esm/utils/sum-usage.test.js +120 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +17 -17
- package/src/components/chat-driver/chat-driver.ts +6 -0
- package/src/components/settings-modal/settings-modal.styles.ts +237 -18
- package/src/components/settings-modal/settings-modal.template.ts +270 -81
- package/src/index.ts +1 -0
- package/src/main/cost-session-banking.test.ts +407 -0
- package/src/main/main.ts +433 -68
- package/src/provider/assistant-app-settings.ts +31 -5
- package/src/state/ai-assistant-slice.test.ts +12 -5
- package/src/state/ai-assistant-slice.ts +21 -13
- package/src/state/debug-event-log.ts +2 -2
- package/src/state/persistence/session-persistence-provider.ts +24 -0
- package/src/state/persistence/session-persistence.integration.test.ts +8 -1
- package/src/state/persistence/session-persister.test.ts +10 -1
- package/src/state/persistence/session-persister.ts +2 -2
- package/src/state/persistence/session-snapshot.test.ts +4 -1
- package/src/state/persistence/session-snapshot.ts +5 -1
- package/src/state/persistence/stateful-restore.e2e.test.ts +9 -1
- package/src/styles/settings-section.ts +40 -0
- package/src/utils/cost-session-history.test.ts +187 -16
- package/src/utils/cost-session-history.ts +142 -23
- package/src/utils/resolve-cost-history-config.ts +10 -3
- package/src/utils/sum-costs.ts +2 -9
- package/src/utils/sum-tokens.test.ts +1 -11
- package/src/utils/sum-tokens.ts +10 -26
- package/src/utils/sum-usage.test.ts +140 -0
- package/src/utils/sum-usage.ts +130 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createSlice } from '@genesislcap/foundation-redux';
|
|
2
|
+
import { emptyUsage, sumUsage } from '../utils/sum-usage';
|
|
2
3
|
/**
|
|
3
4
|
* Build a fresh default session state. A factory (not just a shared const) so
|
|
4
5
|
* each call returns independent collections — `resetSession` can hand back a
|
|
@@ -16,8 +17,7 @@ export function createDefaultSessionState() {
|
|
|
16
17
|
suggestionsState: { status: 'idle' },
|
|
17
18
|
contextTokens: undefined,
|
|
18
19
|
contextLimit: undefined,
|
|
19
|
-
|
|
20
|
-
sessionTokensConsumed: 0,
|
|
20
|
+
sessionUsage: emptyUsage(),
|
|
21
21
|
activeModel: undefined,
|
|
22
22
|
activeProviderName: undefined,
|
|
23
23
|
providerStatuses: [],
|
|
@@ -70,11 +70,8 @@ export const aiAssistantSlice = createSlice({
|
|
|
70
70
|
setContextLimit(state, action) {
|
|
71
71
|
state.contextLimit = action.payload;
|
|
72
72
|
},
|
|
73
|
-
|
|
74
|
-
state.
|
|
75
|
-
},
|
|
76
|
-
setSessionTokensConsumed(state, action) {
|
|
77
|
-
state.sessionTokensConsumed = action.payload;
|
|
73
|
+
setSessionUsage(state, action) {
|
|
74
|
+
state.sessionUsage = action.payload;
|
|
78
75
|
},
|
|
79
76
|
setActiveModel(state, action) {
|
|
80
77
|
state.activeModel = action.payload;
|
|
@@ -135,7 +132,13 @@ export const aiAssistantSlice = createSlice({
|
|
|
135
132
|
state.messages = p.messages;
|
|
136
133
|
state.pinnedAgentName = p.pinnedAgentName;
|
|
137
134
|
state.flowOwnerAgentName = p.flowOwnerAgentName;
|
|
138
|
-
|
|
135
|
+
// Derive the totals from the restored transcript rather than trusting a stored
|
|
136
|
+
// counter. Restore dispatches this action directly, bypassing the host's
|
|
137
|
+
// `messages` setter — the thing that normally recomputes — so a stored total
|
|
138
|
+
// would sit stale (historically at 0) until the next turn arrived. The
|
|
139
|
+
// transcript is the source of truth: per-message usage survives the snapshot,
|
|
140
|
+
// and a compacted summary carries the usage of the turns it replaced.
|
|
141
|
+
state.sessionUsage = sumUsage(p.messages);
|
|
139
142
|
state.contextTokens = p.contextTokens;
|
|
140
143
|
state.contextLimit = p.contextLimit;
|
|
141
144
|
state.activeModel = p.activeModel;
|
|
@@ -8,10 +8,14 @@ Suite('loadSession hydrates the restorable fields and forces idle', () => {
|
|
|
8
8
|
let start = createDefaultSessionState();
|
|
9
9
|
start = reduce(start, setState('loading')); // prove it overrides any prior state
|
|
10
10
|
const payload = {
|
|
11
|
-
|
|
11
|
+
// Usage rides on the message: `loadSession` derives the session totals from the
|
|
12
|
+
// restored transcript rather than from a stored counter, so the cost has to be in
|
|
13
|
+
// the messages for the assertion below to see it.
|
|
14
|
+
messages: [
|
|
15
|
+
{ role: 'user', content: 'hi', cost: 1.25, inputTokens: 100, outputTokens: 20 },
|
|
16
|
+
],
|
|
12
17
|
pinnedAgentName: 'Guided Booking',
|
|
13
18
|
flowOwnerAgentName: 'Guided Booking',
|
|
14
|
-
sessionCostUsd: 1.25,
|
|
15
19
|
contextTokens: 1200,
|
|
16
20
|
contextLimit: 200000,
|
|
17
21
|
activeModel: 'claude-sonnet-4-6',
|
|
@@ -21,7 +25,11 @@ Suite('loadSession hydrates the restorable fields and forces idle', () => {
|
|
|
21
25
|
assert.is(next.messages.length, 1);
|
|
22
26
|
assert.is(next.pinnedAgentName, 'Guided Booking');
|
|
23
27
|
assert.is(next.flowOwnerAgentName, 'Guided Booking');
|
|
24
|
-
assert.is(next.
|
|
28
|
+
assert.is(next.sessionUsage.costUsd, 1.25);
|
|
29
|
+
// Derived from the same walk: 100 prompt tokens with no cache split reported, so all
|
|
30
|
+
// of it is uncached input, plus the 20 generated.
|
|
31
|
+
assert.is(next.sessionUsage.uncachedInputTokens, 100);
|
|
32
|
+
assert.is(next.sessionUsage.outputTokens, 20);
|
|
25
33
|
assert.is(next.contextTokens, 1200);
|
|
26
34
|
assert.is(next.contextLimit, 200000);
|
|
27
35
|
assert.is(next.activeModel, 'claude-sonnet-4-6');
|
|
@@ -35,7 +43,6 @@ Suite('resetSession wipes back to a fresh default', () => {
|
|
|
35
43
|
messages: [],
|
|
36
44
|
pinnedAgentName: 'A',
|
|
37
45
|
flowOwnerAgentName: 'A',
|
|
38
|
-
sessionCostUsd: 9,
|
|
39
46
|
contextTokens: 5,
|
|
40
47
|
contextLimit: 10,
|
|
41
48
|
activeModel: 'm',
|
|
@@ -43,7 +50,7 @@ Suite('resetSession wipes back to a fresh default', () => {
|
|
|
43
50
|
}));
|
|
44
51
|
const cleared = reduce(state, resetSession());
|
|
45
52
|
assert.is(cleared.messages.length, 0);
|
|
46
|
-
assert.is(cleared.
|
|
53
|
+
assert.is(cleared.sessionUsage.costUsd, 0);
|
|
47
54
|
assert.is(cleared.pinnedAgentName, null);
|
|
48
55
|
assert.is(cleared.flowOwnerAgentName, null);
|
|
49
56
|
assert.is(cleared.contextTokens, undefined);
|
|
@@ -195,8 +195,8 @@ export const DEBUG_LOG_README = [
|
|
|
195
195
|
'This is an exported debug log for the Genesis AI assistant. Read it top-to-bottom.',
|
|
196
196
|
'`timeline` is the entire session as one array, already sorted chronologically by `timestamp` (ISO 8601). Every entry has a `kind`.',
|
|
197
197
|
'Timestamps are millisecond-resolution; entries that share the same millisecond are ordered by a fixed kind rank (event, then turn, then message), which is a heuristic and may not reflect exact causal order within that millisecond — e.g. a user message and the turn it triggered, or a final assistant message and its turn.end event, can appear in either order depending on whether they landed in the same millisecond. Read the logical structure of a turn rather than over-interpreting the micro-ordering of co-timestamped entries of different kinds.',
|
|
198
|
-
"kind:'message' — the conversation. `role` is user/assistant/tool/system-event/synthetic-user; `agentName` says which agent produced it; `toolCalls`/`toolResult`/`interaction` carry tool and widget activity; `inputTokens`/`outputTokens`/`cost` are per-message LLM usage, and `externalCostUsd` is any non-LLM cost a widget reported for its own external service calls (folded into the session cost total alongside `cost`). On model-produced assistant messages, `model` is the concrete model id that generated it (e.g. 'gemini-2.5-flash-lite') and `providerName` is the registry slot it resolved under (e.g. a tier name like 'high'/'low', or the default); together they attribute the message — and any tool calls it carries — to an exact model even across a mid-session vendor/tier switch, where one slot name can map to different models before and after the switch. Both are undefined on any entry that is NOT an LLM response: non-assistant roles (user/tool/system-event) and 'synthetic-user' echoes; assistant interaction/widget entries (empty content carrying an `interaction` — a rendered widget, not a model turn); driver-authored assistant fallbacks (the timeout, repeated-malformed-call, and empty-response apology messages); and messages restored from a session persisted before these fields existed. One partial case: on a genuine model turn whose provider exposes no `getStatus` (or reports no model), `providerName` is still set but `model` alone is undefined. A 'synthetic-user' message is a display-only echo of an interaction outcome (e.g. the answer a widget reported): it renders on the user's side of the chat and `agentName` is the agent that created it, but it is never sent to the LLM — so it has no matching 'turn' and the model learns the outcome only from the corresponding tool result.",
|
|
199
|
-
"Sub-agent messages appear inline. When a tool delegates to a sub-agent (via `requestSubAgent`), the sub-agent's whole conversation — its own assistant/tool messages, each with their own `content`/`thinking`/`toolCalls`/`toolResult` and per-message `model`/`providerName`/`inputTokens`/`outputTokens`/`cost` — is hoisted into the timeline as ordinary kind:'message' entries, interleaved by timestamp right after the tool call that spawned them (so you read the delegation top-to-bottom). A hoisted entry is marked: `subAgentDepth` is its delegation depth (1 for a sub-agent, 2 for a sub-agent's sub-agent, …), `subAgentOf` is the id of the parent tool call that spawned it (correlates it back even when two sub-agents run in one parent turn), `subAgentName` is the sub-agent's own name, and `agentName` is rewritten to a `\"<parent> › <sub-agent>\"` breadcrumb (composing when nested, e.g. `\"UI Builder › Planner › Grounding\"`). The sub-agent's per-LLM-call snapshots also surface as kind:'turn' entries with an N-M `turnIndex`, and subagent.started/completed (or subagent.failed) events bracket the run. Per-message `cost` on hoisted entries is already part of the session total (it is summed from the un-flattened history), so summing the top-level timeline does NOT double-count.",
|
|
198
|
+
"kind:'message' — the conversation. `role` is user/assistant/tool/system-event/synthetic-user; `agentName` says which agent produced it; `toolCalls`/`toolResult`/`interaction` carry tool and widget activity; `inputTokens`/`outputTokens`/`cost` are per-message LLM usage, where `inputTokens` is the WHOLE prompt for that request and `cacheReadTokens`/`cacheWriteTokens` BREAK IT DOWN rather than add to it — uncached input is `inputTokens` minus those two, and adding the cache fields to `inputTokens` counts the prompt twice. The cache fields are absent on providers that report no cache split (Gemini reports reads only, since implicit caching bills no write) and on messages persisted before they existed, so read them as 0 when missing. Each bucket bills at a different rate — cache reads a fraction of uncached input, cache writes a premium, output highest — so a large token count at a small cost means the prompt was mostly cache hits. `externalCostUsd` is any non-LLM cost a widget reported for its own external service calls (folded into the session cost total alongside `cost`). On model-produced assistant messages, `model` is the concrete model id that generated it (e.g. 'gemini-2.5-flash-lite') and `providerName` is the registry slot it resolved under (e.g. a tier name like 'high'/'low', or the default); together they attribute the message — and any tool calls it carries — to an exact model even across a mid-session vendor/tier switch, where one slot name can map to different models before and after the switch. Both are undefined on any entry that is NOT an LLM response: non-assistant roles (user/tool/system-event) and 'synthetic-user' echoes; assistant interaction/widget entries (empty content carrying an `interaction` — a rendered widget, not a model turn); driver-authored assistant fallbacks (the timeout, repeated-malformed-call, and empty-response apology messages); and messages restored from a session persisted before these fields existed. One partial case: on a genuine model turn whose provider exposes no `getStatus` (or reports no model), `providerName` is still set but `model` alone is undefined. A 'synthetic-user' message is a display-only echo of an interaction outcome (e.g. the answer a widget reported): it renders on the user's side of the chat and `agentName` is the agent that created it, but it is never sent to the LLM — so it has no matching 'turn' and the model learns the outcome only from the corresponding tool result.",
|
|
199
|
+
"Sub-agent messages appear inline. When a tool delegates to a sub-agent (via `requestSubAgent`), the sub-agent's whole conversation — its own assistant/tool messages, each with their own `content`/`thinking`/`toolCalls`/`toolResult` and per-message `model`/`providerName`/`inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens`/`cost` — is hoisted into the timeline as ordinary kind:'message' entries, interleaved by timestamp right after the tool call that spawned them (so you read the delegation top-to-bottom). A hoisted entry is marked: `subAgentDepth` is its delegation depth (1 for a sub-agent, 2 for a sub-agent's sub-agent, …), `subAgentOf` is the id of the parent tool call that spawned it (correlates it back even when two sub-agents run in one parent turn), `subAgentName` is the sub-agent's own name, and `agentName` is rewritten to a `\"<parent> › <sub-agent>\"` breadcrumb (composing when nested, e.g. `\"UI Builder › Planner › Grounding\"`). The sub-agent's per-LLM-call snapshots also surface as kind:'turn' entries with an N-M `turnIndex`, and subagent.started/completed (or subagent.failed) events bracket the run. Per-message `cost` on hoisted entries is already part of the session total (it is summed from the un-flattened history), so summing the top-level timeline does NOT double-count.",
|
|
200
200
|
"kind:'turn' — one LLM call. `turnIndex` is a string: a top-level turn is the bare counter ('0', '1', …); a sub-agent's turns are numbered under the parent turn that activated them ('3-1', '3-2', …, and a nested sub-agent contributes '3-2-1', …), and `agentName` names the agent that ran the turn. `systemPrompt` and `toolNames` are what the model saw. A systemPrompt of '<repeated — identical to turn N>' was byte-identical to turn N and de-duplicated; the full prompt is shown whenever it changes (often because a stateful agent advanced), so prompt evolution is visible.",
|
|
201
201
|
"kind:'turn'.`agentSnapshot` — the active agent's own view of its internal state, captured at that turn. An agent opts into this by exposing a `getDebugSnapshot()` that returns JSON-serializable per-state info; stateful/flow agents wire it automatically, so you can watch a flow advance turn-by-turn (e.g. current step, cursor, collected fields, pending changes). Absent for agents that don't expose one.",
|
|
202
202
|
"kind:'event' — a meta/lifecycle event. `type` names it (see below); `detail` carries structured data. `detail.placement` is the emitting UI instance: 'bubble' (collapsed), 'panel' (popped-out), or 'standalone'.",
|
|
@@ -61,7 +61,11 @@ Suite('save → reload → restore brings back messages, selection, counters', (
|
|
|
61
61
|
const restored = reduce(freshStore, loadSession(toLoadSessionPayload(snapshot)));
|
|
62
62
|
assert.is(restored.messages.length, 2);
|
|
63
63
|
assert.is(restored.pinnedAgentName, 'Guided Booking');
|
|
64
|
-
|
|
64
|
+
// Cost comes from the restored transcript, NOT the snapshot's stored counter — so it
|
|
65
|
+
// is whatever the messages actually carry. Computed independently here rather than
|
|
66
|
+
// by calling the summing helper under test.
|
|
67
|
+
const expectedCost = restored.messages.reduce((sum, m) => { var _a, _b; return sum + ((_a = m.cost) !== null && _a !== void 0 ? _a : 0) + ((_b = m.externalCostUsd) !== null && _b !== void 0 ? _b : 0); }, 0);
|
|
68
|
+
assert.is(restored.sessionUsage.costUsd, expectedCost);
|
|
65
69
|
assert.is(restored.contextTokens, 800);
|
|
66
70
|
assert.is(restored.contextLimit, 200000);
|
|
67
71
|
assert.is(restored.activeModel, 'claude-sonnet-4-6');
|
|
@@ -225,7 +225,7 @@ export class SessionPersister {
|
|
|
225
225
|
activeAgentName: (_d = s.activeAgent) === null || _d === void 0 ? void 0 : _d.name,
|
|
226
226
|
pinnedAgentName: s.pinnedAgentName,
|
|
227
227
|
flowOwnerAgentName: null,
|
|
228
|
-
sessionCostUsd: s.
|
|
228
|
+
sessionCostUsd: s.sessionUsage.costUsd,
|
|
229
229
|
contextTokens: s.contextTokens,
|
|
230
230
|
contextLimit: s.contextLimit,
|
|
231
231
|
activeModel: s.activeModel,
|
|
@@ -256,7 +256,7 @@ export class SessionPersister {
|
|
|
256
256
|
activeAgentName: (_f = s.activeAgent) === null || _f === void 0 ? void 0 : _f.name,
|
|
257
257
|
pinnedAgentName: s.pinnedAgentName,
|
|
258
258
|
flowOwnerAgentName: s.flowOwnerAgentName,
|
|
259
|
-
sessionCostUsd: s.
|
|
259
|
+
sessionCostUsd: s.sessionUsage.costUsd,
|
|
260
260
|
contextTokens: s.contextTokens,
|
|
261
261
|
contextLimit: s.contextLimit,
|
|
262
262
|
activeModel: s.activeModel,
|
|
@@ -44,7 +44,16 @@ function makePersister(over = {}) {
|
|
|
44
44
|
flowActivationPrompt: null,
|
|
45
45
|
activeAgent: undefined,
|
|
46
46
|
pinnedAgentName: null,
|
|
47
|
-
|
|
47
|
+
// Inline rather than via `emptyUsage()` to keep this mock self-contained. The
|
|
48
|
+
// shape matters: the persister reads `sessionUsage.costUsd`, so a mock missing
|
|
49
|
+
// the object crashes the autosave timer rather than failing an assertion.
|
|
50
|
+
sessionUsage: {
|
|
51
|
+
costUsd: 0,
|
|
52
|
+
uncachedInputTokens: 0,
|
|
53
|
+
cacheReadTokens: 0,
|
|
54
|
+
cacheWriteTokens: 0,
|
|
55
|
+
outputTokens: 0,
|
|
56
|
+
},
|
|
48
57
|
get restoring() {
|
|
49
58
|
return f.restoring;
|
|
50
59
|
},
|
|
@@ -92,12 +92,16 @@ export function isRestorableSession(value, opts) {
|
|
|
92
92
|
* @internal
|
|
93
93
|
*/
|
|
94
94
|
export function toLoadSessionPayload(s) {
|
|
95
|
-
var _a, _b
|
|
95
|
+
var _a, _b;
|
|
96
96
|
return {
|
|
97
97
|
messages: s.messages,
|
|
98
98
|
pinnedAgentName: (_a = s.pinnedAgentName) !== null && _a !== void 0 ? _a : null,
|
|
99
99
|
flowOwnerAgentName: (_b = s.flowOwnerAgentName) !== null && _b !== void 0 ? _b : null,
|
|
100
|
-
|
|
100
|
+
// `sessionCostUsd` is deliberately NOT projected: the slice re-derives cost and
|
|
101
|
+
// token totals from `messages` on load. The snapshot field is still written (its
|
|
102
|
+
// shape is public, and it is a useful cross-check in a saved blob), but it is no
|
|
103
|
+
// longer authoritative — a snapshot saved mid-reset could hold a zeroed total
|
|
104
|
+
// while its transcript still shows the spend.
|
|
101
105
|
contextTokens: s.contextTokens,
|
|
102
106
|
contextLimit: s.contextLimit,
|
|
103
107
|
activeModel: s.activeModel,
|
|
@@ -49,7 +49,10 @@ Suite('toLoadSessionPayload projects only the redux-restorable fields', () => {
|
|
|
49
49
|
const p = toLoadSessionPayload(base());
|
|
50
50
|
assert.is(p.pinnedAgentName, 'A');
|
|
51
51
|
assert.is(p.flowOwnerAgentName, null);
|
|
52
|
-
|
|
52
|
+
// Cost is deliberately NOT projected — the slice re-derives it (and the token
|
|
53
|
+
// buckets) from `messages`, so a stored counter would be a second, divergeable
|
|
54
|
+
// source of truth.
|
|
55
|
+
assert.is(p.sessionCostUsd, undefined);
|
|
53
56
|
assert.is(p.contextTokens, 10);
|
|
54
57
|
assert.is(p.contextLimit, 100);
|
|
55
58
|
assert.is(p.activeModel, 'm');
|
|
@@ -55,7 +55,16 @@ function makeMemoryProvider() {
|
|
|
55
55
|
function makeStoreFake(over = {}) {
|
|
56
56
|
return {
|
|
57
57
|
store: {
|
|
58
|
-
aiAssistant: Object.assign({ messages: [], flowOwnerAgentName: null, flowActivationPrompt: null, activeAgent: undefined, pinnedAgentName: null,
|
|
58
|
+
aiAssistant: Object.assign({ messages: [], flowOwnerAgentName: null, flowActivationPrompt: null, activeAgent: undefined, pinnedAgentName: null,
|
|
59
|
+
// The persister reads `sessionUsage.costUsd` when building a snapshot, so this
|
|
60
|
+
// has to be the object rather than a bare cost scalar.
|
|
61
|
+
sessionUsage: {
|
|
62
|
+
costUsd: 0,
|
|
63
|
+
uncachedInputTokens: 0,
|
|
64
|
+
cacheReadTokens: 0,
|
|
65
|
+
cacheWriteTokens: 0,
|
|
66
|
+
outputTokens: 0,
|
|
67
|
+
}, contextTokens: 0, contextLimit: 0, activeModel: undefined, activeProviderName: undefined, restoring: false }, over),
|
|
59
68
|
},
|
|
60
69
|
actions: {
|
|
61
70
|
aiAssistant: {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { css } from '@genesislcap/web-core';
|
|
2
|
+
/**
|
|
3
|
+
* Heading style for a settings-modal section, published so a host can title its own section.
|
|
4
|
+
*
|
|
5
|
+
* The assistant renders no heading for the `settings-app` slot — no text and no icon — because
|
|
6
|
+
* only the host knows what that section is. The consequence is that a host supplying one has to
|
|
7
|
+
* reproduce the styling of the built-in headings beside it, and a copied rule drifts the moment
|
|
8
|
+
* either side changes. So the declarations live here, are applied by the assistant's own
|
|
9
|
+
* headings, and are exported for the host to apply to its.
|
|
10
|
+
*
|
|
11
|
+
* Compose it into a component's styles and put `ai-settings-section-title` on the heading:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { assistantSettingsSectionTitleStyles } from '@genesislcap/ai-assistant';
|
|
15
|
+
*
|
|
16
|
+
* const styles = css`
|
|
17
|
+
* ${assistantSettingsSectionTitleStyles}
|
|
18
|
+
* :host { display: block; }
|
|
19
|
+
* `;
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Carries no margin on purpose: spacing belongs to the surrounding layout, which differs
|
|
23
|
+
* between the assistant's own sections and a slotted one, and is the single declaration hosts
|
|
24
|
+
* had to diverge on when they copied this rule.
|
|
25
|
+
*
|
|
26
|
+
* @beta
|
|
27
|
+
*/
|
|
28
|
+
export const assistantSettingsSectionTitleStyles = css `
|
|
29
|
+
.ai-settings-section-title {
|
|
30
|
+
display: flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
gap: calc(var(--design-unit) * 2px);
|
|
33
|
+
font-size: 11px;
|
|
34
|
+
font-weight: 600;
|
|
35
|
+
letter-spacing: 0.08em;
|
|
36
|
+
text-transform: uppercase;
|
|
37
|
+
color: var(--neutral-foreground-hint);
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
@@ -1,9 +1,28 @@
|
|
|
1
|
+
import { emptyUsage } from './sum-usage';
|
|
1
2
|
const STORAGE_PREFIX = 'genesis-ai-assistant:cost-history:';
|
|
3
|
+
/**
|
|
4
|
+
* Every bucket must be a number before a row is trusted. Checked per field rather than
|
|
5
|
+
* just testing for an object, because a partially-shaped `usage` would sum as `NaN` and
|
|
6
|
+
* silently poison every total on the Usage tab.
|
|
7
|
+
*/
|
|
8
|
+
function isAggregateUsage(value) {
|
|
9
|
+
if (typeof value !== 'object' || value == null)
|
|
10
|
+
return false;
|
|
11
|
+
const u = value;
|
|
12
|
+
return (typeof u.costUsd === 'number' &&
|
|
13
|
+
typeof u.uncachedInputTokens === 'number' &&
|
|
14
|
+
typeof u.cacheReadTokens === 'number' &&
|
|
15
|
+
typeof u.cacheWriteTokens === 'number' &&
|
|
16
|
+
typeof u.outputTokens === 'number');
|
|
17
|
+
}
|
|
2
18
|
/** localStorage key for a host-scoped cost history list. */
|
|
3
19
|
export function costHistoryStorageKey(scope) {
|
|
4
20
|
return `${STORAGE_PREFIX}${scope}`;
|
|
5
21
|
}
|
|
6
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Load persisted cost sessions for a scope, in whatever order they were stored — this applies no
|
|
24
|
+
* ordering of its own. Display order is the caller's business; see `sortRecordsByRecency`.
|
|
25
|
+
*/
|
|
7
26
|
export function loadCostSessionHistory(scope) {
|
|
8
27
|
if (typeof localStorage === 'undefined')
|
|
9
28
|
return [];
|
|
@@ -31,17 +50,62 @@ export function saveCostSessionHistory(scope, records) {
|
|
|
31
50
|
// Quota exceeded or private browsing — non-fatal.
|
|
32
51
|
}
|
|
33
52
|
}
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Pure upsert — `records` with `record` replacing any row sharing its `projectKey`, or
|
|
55
|
+
* prepended when there is none.
|
|
56
|
+
*
|
|
57
|
+
* An upsert, not an append, because a row stands for a **project's lifetime usage** rather
|
|
58
|
+
* than a visit to it. Appending cut a fresh row per page-hide, so one project accumulated
|
|
59
|
+
* a stack of near-identical rows and its spend was counted once per refresh by anything
|
|
60
|
+
* summing the list.
|
|
61
|
+
*
|
|
62
|
+
* The resulting array order is incidental — it is insertion order, so a project returned to
|
|
63
|
+
* stays where it first landed. Do not read it as recency; `sortRecordsByRecency` is what the
|
|
64
|
+
* Usage tab renders through.
|
|
65
|
+
*
|
|
66
|
+
* Storage-agnostic on purpose: the same merge serves the synchronous `localStorage` path
|
|
67
|
+
* and the async provider-backed one, so the two can never drift on what "upsert" means.
|
|
68
|
+
* The caller owns persistence — see the assistant's `persistCostHistory`.
|
|
69
|
+
*/
|
|
70
|
+
export function upsertRecord(records, record) {
|
|
71
|
+
const at = records.findIndex((r) => r.projectKey === record.projectKey);
|
|
72
|
+
return at === -1 ? [record, ...records] : records.map((r, i) => (i === at ? record : r));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Rows most-recently-worked-on first, as a new array.
|
|
76
|
+
*
|
|
77
|
+
* Applied at read time rather than baked into the stored order, so it covers rows that came from
|
|
78
|
+
* a host provider as well as from `localStorage`, and so no write path has to maintain it.
|
|
79
|
+
*
|
|
80
|
+
* `updatedAt` is ISO-8601, which sorts lexicographically — no date parsing, and no dependence on
|
|
81
|
+
* the strings being valid dates. Sorting by it rather than by insertion order is what makes the
|
|
82
|
+
* list track use: the active project moves to the top on its first upsert and stays there, since
|
|
83
|
+
* its row is rewritten on every transcript change.
|
|
84
|
+
*/
|
|
85
|
+
export function sortRecordsByRecency(records) {
|
|
86
|
+
return [...records].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
39
87
|
}
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
88
|
+
/**
|
|
89
|
+
* The spend a project's next row must carry over from before its current transcript.
|
|
90
|
+
*
|
|
91
|
+
* `transcriptIsAuthoritative` means the open transcript already accounts for this project's
|
|
92
|
+
* history — true exactly when a session snapshot is restored on load. The two branches are
|
|
93
|
+
* not interchangeable, and picking the wrong one is silently destructive:
|
|
94
|
+
*
|
|
95
|
+
* - Authoritative: carry the previous row's own `banked` forward. Taking `usage` instead would
|
|
96
|
+
* re-add the restored history on every reload, the compounding double-count this replaced.
|
|
97
|
+
* - Not authoritative: the whole previous total becomes banked, because nothing in the fresh
|
|
98
|
+
* transcript accounts for it. Taking `banked` instead would rewrite the row down to the
|
|
99
|
+
* current page-load and drop the project's earlier spend.
|
|
100
|
+
*
|
|
101
|
+
* Note both branches are idempotent — the result never includes the current transcript — which
|
|
102
|
+
* is what lets two assistant instances sharing a session write the same row.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveBankedUsage(existing, transcriptIsAuthoritative) {
|
|
105
|
+
if (!existing)
|
|
106
|
+
return emptyUsage();
|
|
107
|
+
const carried = transcriptIsAuthoritative ? existing.banked : existing.usage;
|
|
108
|
+
return carried !== null && carried !== void 0 ? carried : emptyUsage();
|
|
45
109
|
}
|
|
46
110
|
/** Clear all persisted records for a scope. */
|
|
47
111
|
export function clearCostSessionHistory(scope) {
|
|
@@ -62,14 +126,27 @@ export function formatCostSessionDate(iso) {
|
|
|
62
126
|
year: 'numeric',
|
|
63
127
|
}).format(new Date(iso));
|
|
64
128
|
}
|
|
65
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Whether a value is a usable row. Exported because rows arrive by two routes and BOTH need it:
|
|
131
|
+
* `loadCostSessionHistory` filters the `localStorage` path here, and the assistant filters what a
|
|
132
|
+
* host provider returns. A row that skips this check is not merely ignored downstream — a
|
|
133
|
+
* half-shaped `usage` sums to `NaN` through every total, and a missing `updatedAt` throws inside
|
|
134
|
+
* the sort that orders the list, during render.
|
|
135
|
+
*/
|
|
136
|
+
export function isCostSessionRecord(value) {
|
|
66
137
|
if (typeof value !== 'object' || value == null)
|
|
67
138
|
return false;
|
|
68
139
|
const r = value;
|
|
69
140
|
return (typeof r.id === 'string' &&
|
|
141
|
+
// Rows written before the upsert model have no `projectKey` and cannot be keyed
|
|
142
|
+
// to a project, so they are discarded on load rather than silently pooled with
|
|
143
|
+
// the new per-project rows (where they would double-count that project).
|
|
144
|
+
typeof r.projectKey === 'string' &&
|
|
70
145
|
typeof r.title === 'string' &&
|
|
71
|
-
typeof r.
|
|
72
|
-
|
|
73
|
-
|
|
146
|
+
typeof r.updatedAt === 'string' &&
|
|
147
|
+
isAggregateUsage(r.usage) &&
|
|
148
|
+
// Absent is valid (nothing banked / pre-dates the field); present must be whole, since
|
|
149
|
+
// a half-shaped `banked` would poison the lifetime total exactly as a bad `usage` would.
|
|
150
|
+
(r.banked === undefined || isAggregateUsage(r.banked)) &&
|
|
74
151
|
Array.isArray(r.models));
|
|
75
152
|
}
|
|
@@ -1,32 +1,174 @@
|
|
|
1
1
|
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
2
|
-
import {
|
|
2
|
+
import { clearCostSessionHistory, costHistoryStorageKey, formatCostSessionDate, loadCostSessionHistory, resolveBankedUsage, saveCostSessionHistory, sortRecordsByRecency, upsertRecord, } from './cost-session-history';
|
|
3
|
+
import { emptyUsage } from './sum-usage';
|
|
3
4
|
const scope = `test-${Date.now()}`;
|
|
4
|
-
const
|
|
5
|
+
const usage = (costUsd) => ({
|
|
6
|
+
costUsd,
|
|
7
|
+
uncachedInputTokens: 100000,
|
|
8
|
+
cacheReadTokens: 1200000,
|
|
9
|
+
cacheWriteTokens: 60000,
|
|
10
|
+
outputTokens: 40000,
|
|
11
|
+
});
|
|
12
|
+
const sampleRecord = (overrides = {}) => (Object.assign({ id: 'rec-1', projectKey: 'proj-1', title: 'Equity Options Pricer', updatedAt: '2026-06-14T12:00:00.000Z', usage: usage(1.86),
|
|
13
|
+
// Present by default so `delete record.banked` in the legacy-row cases actually removes
|
|
14
|
+
// something, and so the storage round-trip covers it.
|
|
15
|
+
banked: usage(0.5), models: [{ model: 'claude-sonnet-4-6', provider: 'anthropic' }] }, overrides));
|
|
5
16
|
const suite = createLogicSuite('costSessionHistory');
|
|
6
17
|
suite('uses a scoped storage key', () => {
|
|
7
18
|
assert.is(costHistoryStorageKey('my-app'), 'genesis-ai-assistant:cost-history:my-app');
|
|
8
19
|
});
|
|
9
|
-
suite('
|
|
20
|
+
suite('upsertRecord prepends a project it has not seen', () => {
|
|
21
|
+
var _a, _b;
|
|
22
|
+
const first = upsertRecord([], sampleRecord({ id: 'a', projectKey: 'a', title: 'First' }));
|
|
23
|
+
const both = upsertRecord(first, sampleRecord({ id: 'b', projectKey: 'b', title: 'Second' }));
|
|
24
|
+
assert.is(both.length, 2);
|
|
25
|
+
// Insertion order, not recency — display ordering is sortRecordsByRecency's job.
|
|
26
|
+
assert.is((_a = both[0]) === null || _a === void 0 ? void 0 : _a.id, 'b', 'most recently added is prepended');
|
|
27
|
+
assert.is((_b = both[1]) === null || _b === void 0 ? void 0 : _b.id, 'a');
|
|
28
|
+
});
|
|
29
|
+
suite('upsertRecord replaces a project in place rather than stacking a second row', () => {
|
|
10
30
|
var _a, _b;
|
|
31
|
+
const seeded = [
|
|
32
|
+
sampleRecord({ id: 'b', projectKey: 'b', usage: usage(2) }),
|
|
33
|
+
sampleRecord({ id: 'a', projectKey: 'a', usage: usage(1) }),
|
|
34
|
+
];
|
|
35
|
+
// Re-reporting a project must REPLACE its row. Appending here is what made a single
|
|
36
|
+
// project's spend count once per refresh in any total summed over the list.
|
|
37
|
+
const next = upsertRecord(seeded, sampleRecord({ id: 'a', projectKey: 'a', usage: usage(9.5) }));
|
|
38
|
+
assert.is(next.length, 2);
|
|
39
|
+
// Replaced in place rather than moved — the stored array is not a recency ordering.
|
|
40
|
+
assert.is((_a = next[1]) === null || _a === void 0 ? void 0 : _a.projectKey, 'a');
|
|
41
|
+
assert.is((_b = next[1]) === null || _b === void 0 ? void 0 : _b.usage.costUsd, 9.5);
|
|
42
|
+
assert.is(next.reduce((sum, r) => sum + r.usage.costUsd, 0), 11.5);
|
|
43
|
+
});
|
|
44
|
+
suite('round-trips records through storage', () => {
|
|
45
|
+
var _a, _b, _c, _d;
|
|
46
|
+
clearCostSessionHistory(scope);
|
|
47
|
+
const records = upsertRecord([], sampleRecord({ id: 'a', projectKey: 'a', usage: usage(3.5) }));
|
|
48
|
+
saveCostSessionHistory(scope, records);
|
|
49
|
+
const loaded = loadCostSessionHistory(scope);
|
|
50
|
+
assert.is(loaded.length, 1);
|
|
51
|
+
assert.is((_a = loaded[0]) === null || _a === void 0 ? void 0 : _a.usage.costUsd, 3.5);
|
|
52
|
+
assert.is((_b = loaded[0]) === null || _b === void 0 ? void 0 : _b.usage.cacheReadTokens, 1200000, 'buckets survive serialization');
|
|
53
|
+
assert.is((_d = (_c = loaded[0]) === null || _c === void 0 ? void 0 : _c.banked) === null || _d === void 0 ? void 0 : _d.costUsd, 0.5, 'banked survives serialization');
|
|
54
|
+
clearCostSessionHistory(scope);
|
|
55
|
+
});
|
|
56
|
+
suite('discards legacy rows that predate the per-project key', () => {
|
|
57
|
+
var _a;
|
|
11
58
|
clearCostSessionHistory(scope);
|
|
12
|
-
|
|
13
|
-
|
|
59
|
+
const legacy = sampleRecord({ id: 'old' });
|
|
60
|
+
delete legacy.projectKey;
|
|
61
|
+
saveCostSessionHistory(scope, [legacy, sampleRecord({ id: 'new' })]);
|
|
14
62
|
const loaded = loadCostSessionHistory(scope);
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
assert.is(
|
|
63
|
+
// A keyless row can never be matched to a project, so pooling it with the new rows
|
|
64
|
+
// would permanently double-count whichever project it came from.
|
|
65
|
+
assert.is(loaded.length, 1);
|
|
66
|
+
assert.is((_a = loaded[0]) === null || _a === void 0 ? void 0 : _a.id, 'new');
|
|
18
67
|
clearCostSessionHistory(scope);
|
|
19
68
|
});
|
|
20
|
-
suite('
|
|
69
|
+
suite('discards rows whose usage is not fully numeric', () => {
|
|
21
70
|
var _a;
|
|
22
71
|
clearCostSessionHistory(scope);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
72
|
+
const broken = sampleRecord({ id: 'broken' });
|
|
73
|
+
// A half-shaped `usage` is worse than a missing row: it sums as NaN and poisons every
|
|
74
|
+
// total on the Usage tab, with no clue where the NaN came from.
|
|
75
|
+
delete broken.usage.outputTokens;
|
|
76
|
+
saveCostSessionHistory(scope, [broken, sampleRecord({ id: 'ok', projectKey: 'ok' })]);
|
|
77
|
+
const loaded = loadCostSessionHistory(scope);
|
|
78
|
+
assert.is(loaded.length, 1);
|
|
79
|
+
assert.is((_a = loaded[0]) === null || _a === void 0 ? void 0 : _a.id, 'ok');
|
|
80
|
+
clearCostSessionHistory(scope);
|
|
81
|
+
});
|
|
82
|
+
suite('discards rows whose banked figure is half-shaped', () => {
|
|
83
|
+
var _a;
|
|
84
|
+
clearCostSessionHistory(scope);
|
|
85
|
+
const broken = sampleRecord({ id: 'broken', banked: Object.assign({}, usage(2)) });
|
|
86
|
+
// A malformed `banked` poisons the lifetime total exactly as a malformed `usage` does,
|
|
87
|
+
// since the two are added together to produce it.
|
|
88
|
+
delete broken.banked.cacheWriteTokens;
|
|
89
|
+
saveCostSessionHistory(scope, [broken, sampleRecord({ id: 'ok', projectKey: 'ok' })]);
|
|
90
|
+
const loaded = loadCostSessionHistory(scope);
|
|
91
|
+
assert.is(loaded.length, 1);
|
|
92
|
+
assert.is((_a = loaded[0]) === null || _a === void 0 ? void 0 : _a.id, 'ok');
|
|
93
|
+
clearCostSessionHistory(scope);
|
|
94
|
+
});
|
|
95
|
+
suite('keeps a row whose banked figure is absent', () => {
|
|
96
|
+
clearCostSessionHistory(scope);
|
|
97
|
+
const legacy = sampleRecord({ id: 'legacy' });
|
|
98
|
+
delete legacy.banked;
|
|
99
|
+
saveCostSessionHistory(scope, [legacy]);
|
|
100
|
+
assert.is(loadCostSessionHistory(scope).length, 1, 'absent is valid, unlike half-shaped');
|
|
27
101
|
clearCostSessionHistory(scope);
|
|
28
102
|
});
|
|
29
103
|
suite('formats session dates for display', () => {
|
|
30
104
|
assert.is(formatCostSessionDate('2026-06-14T12:00:00.000Z'), 'Jun 14, 2026');
|
|
31
105
|
});
|
|
106
|
+
suite('sortRecordsByRecency puts the most recently worked-on project first', () => {
|
|
107
|
+
const records = [
|
|
108
|
+
sampleRecord({ id: 'old', projectKey: 'old', updatedAt: '2026-01-01T00:00:00.000Z' }),
|
|
109
|
+
sampleRecord({ id: 'newest', projectKey: 'newest', updatedAt: '2026-08-04T09:00:00.000Z' }),
|
|
110
|
+
sampleRecord({ id: 'mid', projectKey: 'mid', updatedAt: '2026-05-01T00:00:00.000Z' }),
|
|
111
|
+
];
|
|
112
|
+
const sorted = sortRecordsByRecency(records);
|
|
113
|
+
assert.equal(sorted.map((r) => r.id), ['newest', 'mid', 'old']);
|
|
114
|
+
// Read-time ordering, so the caller's array must be left alone.
|
|
115
|
+
assert.equal(records.map((r) => r.id), ['old', 'newest', 'mid'], 'input not mutated');
|
|
116
|
+
});
|
|
117
|
+
suite('sortRecordsByRecency overrides insertion order, which upsert leaves alone', () => {
|
|
118
|
+
var _a, _b;
|
|
119
|
+
// The behaviour this fixes: revisiting a project replaces its row IN PLACE, so insertion order
|
|
120
|
+
// strands it wherever it was first seen. Display order must come from updatedAt instead.
|
|
121
|
+
let records = upsertRecord([], sampleRecord({ id: 'a', projectKey: 'a', updatedAt: '2026-01-01T00:00:00.000Z' }));
|
|
122
|
+
records = upsertRecord(records, sampleRecord({ id: 'b', projectKey: 'b', updatedAt: '2026-02-01T00:00:00.000Z' }));
|
|
123
|
+
// Work on 'a' again — newer than 'b', but upsert keeps it in slot 1.
|
|
124
|
+
records = upsertRecord(records, sampleRecord({ id: 'a', projectKey: 'a', updatedAt: '2026-03-01T00:00:00.000Z' }));
|
|
125
|
+
assert.is((_a = records[1]) === null || _a === void 0 ? void 0 : _a.id, 'a', 'stored order is unchanged by the revisit');
|
|
126
|
+
assert.is((_b = sortRecordsByRecency(records)[0]) === null || _b === void 0 ? void 0 : _b.id, 'a', 'display order tracks the revisit');
|
|
127
|
+
});
|
|
128
|
+
suite('banks nothing for a project with no prior row', () => {
|
|
129
|
+
assert.equal(resolveBankedUsage(undefined, true), emptyUsage());
|
|
130
|
+
assert.equal(resolveBankedUsage(undefined, false), emptyUsage());
|
|
131
|
+
});
|
|
132
|
+
suite('carries the prior banked figure forward when the transcript is authoritative', () => {
|
|
133
|
+
// Restored transcript: it already re-proves everything after the row's own banked figure,
|
|
134
|
+
// so only that figure carries over. Taking `usage` here is the compounding double-count.
|
|
135
|
+
const prior = sampleRecord({ usage: usage(10), banked: Object.assign({}, usage(4)) });
|
|
136
|
+
assert.is(resolveBankedUsage(prior, true).costUsd, 4);
|
|
137
|
+
});
|
|
138
|
+
suite('banks the whole prior total when the transcript starts empty', () => {
|
|
139
|
+
// Nothing on screen accounts for the previous total, so all of it must carry over or the
|
|
140
|
+
// next turn rewrites the row down to just that turn.
|
|
141
|
+
const prior = sampleRecord({ usage: usage(10), banked: Object.assign({}, usage(4)) });
|
|
142
|
+
assert.is(resolveBankedUsage(prior, false).costUsd, 10);
|
|
143
|
+
});
|
|
144
|
+
suite('treats a row predating the banked field as nothing banked', () => {
|
|
145
|
+
const legacy = sampleRecord({ usage: usage(7) });
|
|
146
|
+
delete legacy.banked;
|
|
147
|
+
assert.equal(resolveBankedUsage(legacy, true), emptyUsage());
|
|
148
|
+
// Without persistence the legacy total still carries, so upgrading loses no spend.
|
|
149
|
+
assert.is(resolveBankedUsage(legacy, false).costUsd, 7);
|
|
150
|
+
});
|
|
151
|
+
suite('is stable across repeated reloads — the regression this guards', () => {
|
|
152
|
+
// Simulate the reload cycle: usage = banked + whatever the transcript proves. With a
|
|
153
|
+
// restored transcript (worth 6 on top of 4 banked) the row must land on 10 every time,
|
|
154
|
+
// not 10 → 14 → 18 as it did when a reload re-banked the restored history.
|
|
155
|
+
const transcript = 6;
|
|
156
|
+
let row = sampleRecord({ usage: usage(10), banked: Object.assign({}, usage(4)) });
|
|
157
|
+
for (let reload = 0; reload < 3; reload += 1) {
|
|
158
|
+
const banked = resolveBankedUsage(row, true);
|
|
159
|
+
row = Object.assign(Object.assign({}, row), { banked, usage: Object.assign({}, usage(banked.costUsd + transcript)) });
|
|
160
|
+
assert.is(row.usage.costUsd, 10, `reload ${reload}`);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
suite('accumulates across reloads when the transcript does not persist', () => {
|
|
164
|
+
// The mirror case: each page-load starts from an empty transcript, so the row must grow by
|
|
165
|
+
// whatever that load spends rather than being overwritten by it.
|
|
166
|
+
let row = sampleRecord({ usage: usage(10), banked: Object.assign({}, usage(4)) });
|
|
167
|
+
for (const spend of [2, 3]) {
|
|
168
|
+
const banked = resolveBankedUsage(row, false);
|
|
169
|
+
row = Object.assign(Object.assign({}, row), { banked, usage: Object.assign({}, usage(banked.costUsd + spend)) });
|
|
170
|
+
}
|
|
171
|
+
// 10 banked, +2 → 12; then 12 banked, +3 → 15. Never decreases.
|
|
172
|
+
assert.is(row.usage.costUsd, 15);
|
|
173
|
+
});
|
|
32
174
|
suite.run();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Resolve `chatConfig.costHistory` with defaults applied. */
|
|
2
2
|
export function resolveCostHistoryConfig(chatConfig) {
|
|
3
|
-
var _a, _b, _c;
|
|
3
|
+
var _a, _b, _c, _d;
|
|
4
4
|
const cost = chatConfig.costHistory;
|
|
5
5
|
return {
|
|
6
6
|
enabled: (cost === null || cost === void 0 ? void 0 : cost.enabled) === true,
|
|
@@ -9,5 +9,6 @@ export function resolveCostHistoryConfig(chatConfig) {
|
|
|
9
9
|
showCost: (cost === null || cost === void 0 ? void 0 : cost.showCost) !== false,
|
|
10
10
|
showTokens: (cost === null || cost === void 0 ? void 0 : cost.showTokens) !== false,
|
|
11
11
|
badgeLabel: (_c = cost === null || cost === void 0 ? void 0 : cost.badgeLabel) !== null && _c !== void 0 ? _c : 'Currently building',
|
|
12
|
+
summaryHint: ((_d = cost === null || cost === void 0 ? void 0 : cost.summaryHint) === null || _d === void 0 ? void 0 : _d.trim()) || undefined,
|
|
12
13
|
};
|
|
13
14
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sumUsage } from './sum-usage';
|
|
1
2
|
/**
|
|
2
3
|
* Sum cost across a message list: each message's provider-reported LLM `cost`
|
|
3
4
|
* plus any `externalCostUsd` a widget reported for its own external service
|
|
@@ -11,17 +12,5 @@
|
|
|
11
12
|
* @internal
|
|
12
13
|
*/
|
|
13
14
|
export function sumCosts(messages) {
|
|
14
|
-
|
|
15
|
-
let total = 0;
|
|
16
|
-
for (const m of messages) {
|
|
17
|
-
if (m.cost != null)
|
|
18
|
-
total += m.cost;
|
|
19
|
-
if (m.externalCostUsd != null)
|
|
20
|
-
total += m.externalCostUsd;
|
|
21
|
-
for (const tc of (_a = m.toolCalls) !== null && _a !== void 0 ? _a : []) {
|
|
22
|
-
if (tc.subAgentTrace)
|
|
23
|
-
total += sumCosts(tc.subAgentTrace);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return total;
|
|
15
|
+
return sumUsage(messages).costUsd;
|
|
27
16
|
}
|