@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.
Files changed (91) hide show
  1. package/dist/ai-assistant.api.json +544 -82
  2. package/dist/ai-assistant.d.ts +324 -36
  3. package/dist/chat-driver.cjs +94 -22
  4. package/dist/chat-driver.cjs.map +3 -3
  5. package/dist/chat-driver.mjs +94 -22
  6. package/dist/chat-driver.mjs.map +3 -3
  7. package/dist/custom-elements.json +303 -36
  8. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  9. package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  10. package/dist/dts/components/settings-modal/settings-modal.template.d.ts +9 -2
  11. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  12. package/dist/dts/index.d.ts +1 -0
  13. package/dist/dts/index.d.ts.map +1 -1
  14. package/dist/dts/main/cost-session-banking.test.d.ts +2 -0
  15. package/dist/dts/main/cost-session-banking.test.d.ts.map +1 -0
  16. package/dist/dts/main/main.d.ts +184 -24
  17. package/dist/dts/main/main.d.ts.map +1 -1
  18. package/dist/dts/provider/assistant-app-settings.d.ts +30 -5
  19. package/dist/dts/provider/assistant-app-settings.d.ts.map +1 -1
  20. package/dist/dts/state/ai-assistant-slice.d.ts +11 -8
  21. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  22. package/dist/dts/state/persistence/session-persistence-provider.d.ts +23 -0
  23. package/dist/dts/state/persistence/session-persistence-provider.d.ts.map +1 -1
  24. package/dist/dts/state/persistence/session-snapshot.d.ts.map +1 -1
  25. package/dist/dts/state/session-store.d.ts +1 -2
  26. package/dist/dts/state/session-store.d.ts.map +1 -1
  27. package/dist/dts/styles/settings-section.d.ts +29 -0
  28. package/dist/dts/styles/settings-section.d.ts.map +1 -0
  29. package/dist/dts/utils/cost-session-history.d.ts +103 -12
  30. package/dist/dts/utils/cost-session-history.d.ts.map +1 -1
  31. package/dist/dts/utils/resolve-cost-history-config.d.ts +9 -3
  32. package/dist/dts/utils/resolve-cost-history-config.d.ts.map +1 -1
  33. package/dist/dts/utils/sum-costs.d.ts.map +1 -1
  34. package/dist/dts/utils/sum-tokens.d.ts +8 -8
  35. package/dist/dts/utils/sum-tokens.d.ts.map +1 -1
  36. package/dist/dts/utils/sum-usage.d.ts +59 -0
  37. package/dist/dts/utils/sum-usage.d.ts.map +1 -0
  38. package/dist/dts/utils/sum-usage.test.d.ts +2 -0
  39. package/dist/dts/utils/sum-usage.test.d.ts.map +1 -0
  40. package/dist/esm/components/chat-driver/chat-driver.js +6 -0
  41. package/dist/esm/components/settings-modal/settings-modal.styles.js +237 -18
  42. package/dist/esm/components/settings-modal/settings-modal.template.js +229 -73
  43. package/dist/esm/index.js +1 -0
  44. package/dist/esm/main/cost-session-banking.test.js +308 -0
  45. package/dist/esm/main/main.js +424 -71
  46. package/dist/esm/state/ai-assistant-slice.js +11 -8
  47. package/dist/esm/state/ai-assistant-slice.test.js +12 -5
  48. package/dist/esm/state/debug-event-log.js +2 -2
  49. package/dist/esm/state/persistence/session-persistence.integration.test.js +5 -1
  50. package/dist/esm/state/persistence/session-persister.js +2 -2
  51. package/dist/esm/state/persistence/session-persister.test.js +10 -1
  52. package/dist/esm/state/persistence/session-snapshot.js +6 -2
  53. package/dist/esm/state/persistence/session-snapshot.test.js +4 -1
  54. package/dist/esm/state/persistence/stateful-restore.e2e.test.js +10 -1
  55. package/dist/esm/styles/settings-section.js +39 -0
  56. package/dist/esm/utils/cost-session-history.js +92 -15
  57. package/dist/esm/utils/cost-session-history.test.js +155 -13
  58. package/dist/esm/utils/resolve-cost-history-config.js +2 -1
  59. package/dist/esm/utils/sum-costs.js +2 -13
  60. package/dist/esm/utils/sum-tokens.js +10 -27
  61. package/dist/esm/utils/sum-tokens.test.js +1 -5
  62. package/dist/esm/utils/sum-usage.js +123 -0
  63. package/dist/esm/utils/sum-usage.test.js +120 -0
  64. package/dist/tsconfig.tsbuildinfo +1 -1
  65. package/package.json +17 -17
  66. package/src/components/chat-driver/chat-driver.ts +6 -0
  67. package/src/components/settings-modal/settings-modal.styles.ts +237 -18
  68. package/src/components/settings-modal/settings-modal.template.ts +270 -81
  69. package/src/index.ts +1 -0
  70. package/src/main/cost-session-banking.test.ts +407 -0
  71. package/src/main/main.ts +433 -68
  72. package/src/provider/assistant-app-settings.ts +31 -5
  73. package/src/state/ai-assistant-slice.test.ts +12 -5
  74. package/src/state/ai-assistant-slice.ts +21 -13
  75. package/src/state/debug-event-log.ts +2 -2
  76. package/src/state/persistence/session-persistence-provider.ts +24 -0
  77. package/src/state/persistence/session-persistence.integration.test.ts +8 -1
  78. package/src/state/persistence/session-persister.test.ts +10 -1
  79. package/src/state/persistence/session-persister.ts +2 -2
  80. package/src/state/persistence/session-snapshot.test.ts +4 -1
  81. package/src/state/persistence/session-snapshot.ts +5 -1
  82. package/src/state/persistence/stateful-restore.e2e.test.ts +9 -1
  83. package/src/styles/settings-section.ts +40 -0
  84. package/src/utils/cost-session-history.test.ts +187 -16
  85. package/src/utils/cost-session-history.ts +142 -23
  86. package/src/utils/resolve-cost-history-config.ts +10 -3
  87. package/src/utils/sum-costs.ts +2 -9
  88. package/src/utils/sum-tokens.test.ts +1 -11
  89. package/src/utils/sum-tokens.ts +10 -26
  90. package/src/utils/sum-usage.test.ts +140 -0
  91. package/src/utils/sum-usage.ts +130 -0
@@ -21,16 +21,42 @@ export interface AssistantAppSettingsToggle {
21
21
  }
22
22
 
23
23
  /**
24
- * App-supplied settings for the platform settings modal UI Builder section.
25
- * Register a concrete implementation on the DI token at app bootstrap; the
26
- * assistant renders toggles with the same row layout as AI Chat Bot settings.
24
+ * Optional heading above a provider's toggle group.
25
+ *
26
+ * Entirely host-supplied the assistant has no default title and no default icon, because the
27
+ * section belongs to the host. Omit it and the toggles render bare, which is right when the
28
+ * host's slotted content already titles them.
29
+ *
30
+ * @beta
31
+ */
32
+ export interface AssistantAppSettingsHeading {
33
+ readonly title: string;
34
+ /**
35
+ * Icon name for the design system's icon element (a Font Awesome free name, e.g.
36
+ * `'object-group'`). Omitted renders text only; an unknown name renders nothing at all, so
37
+ * check it against the installed Font Awesome set.
38
+ */
39
+ readonly icon?: string;
40
+ }
41
+
42
+ /**
43
+ * App-supplied settings for the host's own section of the settings modal. Register a concrete
44
+ * implementation on the DI token at app bootstrap; the assistant renders the toggles with the
45
+ * same row layout as AI Chat Bot settings.
46
+ *
47
+ * The section itself is untitled by the assistant — no heading text and no icon — because only
48
+ * the host knows what it is. Title it from the `settings-app` slot, which renders above these
49
+ * toggles, and apply `assistantSettingsSectionTitleStyles` to match the built-in headings.
27
50
  *
28
51
  * @beta
29
52
  */
30
53
  export interface AssistantAppSettingsProvider {
31
- /** Section heading. Default: `"UI Builder Settings"`. */
32
- readonly sectionTitle?: string;
33
54
  readonly toggles: readonly AssistantAppSettingsToggle[];
55
+ /**
56
+ * Heading for the toggle group, for hosts that put other controls in the `settings-app` slot
57
+ * and need these distinguished from them. No default: absent means no heading.
58
+ */
59
+ readonly togglesHeading?: AssistantAppSettingsHeading;
34
60
  getValue(id: string): boolean;
35
61
  setValue(id: string, value: boolean): void;
36
62
  /** Notify the assistant to re-read toggle values / visibility. */
@@ -28,10 +28,14 @@ Suite('loadSession hydrates the restorable fields and forces idle', () => {
28
28
  let start = createDefaultSessionState();
29
29
  start = reduce(start, setState('loading')); // prove it overrides any prior state
30
30
  const payload: LoadSessionPayload = {
31
- messages: [{ role: 'user', content: 'hi' }] as ChatMessage[],
31
+ // Usage rides on the message: `loadSession` derives the session totals from the
32
+ // restored transcript rather than from a stored counter, so the cost has to be in
33
+ // the messages for the assertion below to see it.
34
+ messages: [
35
+ { role: 'user', content: 'hi', cost: 1.25, inputTokens: 100, outputTokens: 20 },
36
+ ] as ChatMessage[],
32
37
  pinnedAgentName: 'Guided Booking',
33
38
  flowOwnerAgentName: 'Guided Booking',
34
- sessionCostUsd: 1.25,
35
39
  contextTokens: 1200,
36
40
  contextLimit: 200000,
37
41
  activeModel: 'claude-sonnet-4-6',
@@ -41,7 +45,11 @@ Suite('loadSession hydrates the restorable fields and forces idle', () => {
41
45
  assert.is(next.messages.length, 1);
42
46
  assert.is(next.pinnedAgentName, 'Guided Booking');
43
47
  assert.is(next.flowOwnerAgentName, 'Guided Booking');
44
- assert.is(next.sessionCostUsd, 1.25);
48
+ assert.is(next.sessionUsage.costUsd, 1.25);
49
+ // Derived from the same walk: 100 prompt tokens with no cache split reported, so all
50
+ // of it is uncached input, plus the 20 generated.
51
+ assert.is(next.sessionUsage.uncachedInputTokens, 100);
52
+ assert.is(next.sessionUsage.outputTokens, 20);
45
53
  assert.is(next.contextTokens, 1200);
46
54
  assert.is(next.contextLimit, 200000);
47
55
  assert.is(next.activeModel, 'claude-sonnet-4-6');
@@ -58,7 +66,6 @@ Suite('resetSession wipes back to a fresh default', () => {
58
66
  messages: [],
59
67
  pinnedAgentName: 'A',
60
68
  flowOwnerAgentName: 'A',
61
- sessionCostUsd: 9,
62
69
  contextTokens: 5,
63
70
  contextLimit: 10,
64
71
  activeModel: 'm',
@@ -67,7 +74,7 @@ Suite('resetSession wipes back to a fresh default', () => {
67
74
  );
68
75
  const cleared = reduce(state, resetSession());
69
76
  assert.is(cleared.messages.length, 0);
70
- assert.is(cleared.sessionCostUsd, 0);
77
+ assert.is(cleared.sessionUsage.costUsd, 0);
71
78
  assert.is(cleared.pinnedAgentName, null);
72
79
  assert.is(cleared.flowOwnerAgentName, null);
73
80
  assert.is(cleared.contextTokens, undefined);
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ AggregateUsage,
2
3
  AIProviderRegistryStatusEntry,
3
4
  ChatInputDuringExecutionMode,
4
5
  ChatMessage,
@@ -7,6 +8,7 @@ import type { PayloadAction } from '@genesislcap/foundation-redux';
7
8
  import { createSlice } from '@genesislcap/foundation-redux';
8
9
  import type { AgentConfig } from '../config/config';
9
10
  import type { AiAssistantAnimation, AiAssistantState, SuggestionsState } from '../main/main.types';
11
+ import { emptyUsage, sumUsage } from '../utils/sum-usage';
10
12
 
11
13
  /**
12
14
  * A single in-flight per-call chat-input override pushed by a `requestSubAgent`
@@ -40,10 +42,15 @@ export interface AiAssistantSessionState {
40
42
  suggestionsState: SuggestionsState;
41
43
  contextTokens: number | undefined;
42
44
  contextLimit: number | undefined;
43
- /** Aggregated USD cost across every chat turn in this session. */
44
- sessionCostUsd: number;
45
- /** Cumulative input + output tokens across every chat turn in this session. */
46
- sessionTokensConsumed: number;
45
+ /**
46
+ * Cost and per-bucket token totals across every chat turn in this session,
47
+ * including sub-agent turns and spend banked by a compaction.
48
+ *
49
+ * One field rather than a cost scalar plus a token scalar: they are derived from
50
+ * the same walk of the transcript, so keeping them together makes it impossible
51
+ * for the two to disagree about which turns they counted.
52
+ */
53
+ sessionUsage: AggregateUsage;
47
54
  /** Active model id (e.g. `claude-sonnet-4-6`), resolved on connect. */
48
55
  activeModel: string | undefined;
49
56
  /**
@@ -137,7 +144,6 @@ export interface LoadSessionPayload {
137
144
  messages: ChatMessage[];
138
145
  pinnedAgentName: string | null;
139
146
  flowOwnerAgentName: string | null;
140
- sessionCostUsd: number;
141
147
  contextTokens: number | undefined;
142
148
  contextLimit: number | undefined;
143
149
  activeModel: string | undefined;
@@ -161,8 +167,7 @@ export function createDefaultSessionState(): AiAssistantSessionState {
161
167
  suggestionsState: { status: 'idle' },
162
168
  contextTokens: undefined,
163
169
  contextLimit: undefined,
164
- sessionCostUsd: 0,
165
- sessionTokensConsumed: 0,
170
+ sessionUsage: emptyUsage(),
166
171
  activeModel: undefined,
167
172
  activeProviderName: undefined,
168
173
  providerStatuses: [],
@@ -217,11 +222,8 @@ export const aiAssistantSlice = createSlice({
217
222
  setContextLimit(state, action: PayloadAction<number | undefined>) {
218
223
  state.contextLimit = action.payload;
219
224
  },
220
- setSessionCostUsd(state, action: PayloadAction<number>) {
221
- state.sessionCostUsd = action.payload;
222
- },
223
- setSessionTokensConsumed(state, action: PayloadAction<number>) {
224
- state.sessionTokensConsumed = action.payload;
225
+ setSessionUsage(state, action: PayloadAction<AggregateUsage>) {
226
+ state.sessionUsage = action.payload;
225
227
  },
226
228
  setActiveModel(state, action: PayloadAction<string | undefined>) {
227
229
  state.activeModel = action.payload;
@@ -282,7 +284,13 @@ export const aiAssistantSlice = createSlice({
282
284
  state.messages = p.messages;
283
285
  state.pinnedAgentName = p.pinnedAgentName;
284
286
  state.flowOwnerAgentName = p.flowOwnerAgentName;
285
- state.sessionCostUsd = p.sessionCostUsd;
287
+ // Derive the totals from the restored transcript rather than trusting a stored
288
+ // counter. Restore dispatches this action directly, bypassing the host's
289
+ // `messages` setter — the thing that normally recomputes — so a stored total
290
+ // would sit stale (historically at 0) until the next turn arrived. The
291
+ // transcript is the source of truth: per-message usage survives the snapshot,
292
+ // and a compacted summary carries the usage of the turns it replaced.
293
+ state.sessionUsage = sumUsage(p.messages);
286
294
  state.contextTokens = p.contextTokens;
287
295
  state.contextLimit = p.contextLimit;
288
296
  state.activeModel = p.activeModel;
@@ -313,8 +313,8 @@ export const DEBUG_LOG_README: readonly string[] = [
313
313
  'This is an exported debug log for the Genesis AI assistant. Read it top-to-bottom.',
314
314
  '`timeline` is the entire session as one array, already sorted chronologically by `timestamp` (ISO 8601). Every entry has a `kind`.',
315
315
  '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.',
316
- "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.",
317
- "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.",
316
+ "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.",
317
+ "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.",
318
318
  "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.",
319
319
  "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.",
320
320
  "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'.",
@@ -1,3 +1,4 @@
1
+ import type { CostSessionRecord } from '../../utils/cost-session-history';
1
2
  import type { DiagnosticEntry } from './diagnostics';
2
3
  import type { PersistedSession } from './session-snapshot';
3
4
 
@@ -43,6 +44,29 @@ export interface SessionPersistenceProvider {
43
44
  /** Persist the user's UI preferences for `sessionKey` (kept across `clear`). */
44
45
  savePreferences?(sessionKey: string, preferences: SessionPreferences): Promise<void>;
45
46
 
47
+ /**
48
+ * Scoped cost/build history — the per-project usage rows behind the Usage tab.
49
+ *
50
+ * ‼️ **Keyed by SCOPE, not `sessionKey`**, unlike every other method on this interface.
51
+ * A row holds one project's lifetime usage and the list spans projects, so keying it
52
+ * per session would defeat the model. The scope comes from
53
+ * `chatConfig.costHistory.scope`, falling back to the element `id`, then `'default'`.
54
+ *
55
+ * Optional: when a provider omits these, the assistant falls back to `localStorage`, so
56
+ * hosts that don't implement them are unaffected. Implementing them moves the ledger to
57
+ * the host's own backend, which is what stops it being stranded in one browser.
58
+ *
59
+ * Read/written **independently of the `enabled` toggle** (as with preferences and
60
+ * diagnostics): usage accounting is not conversation content, so a user turning off
61
+ * chat retention should not lose their cost ledger. Best-effort — a failed write must
62
+ * never affect the chat.
63
+ *
64
+ * `undefined` from `loadCostHistory` means "nothing stored", the same as `[]`.
65
+ */
66
+ loadCostHistory?(scope: string): Promise<CostSessionRecord[] | undefined>;
67
+ /** Replace the stored row list for `scope`. See {@link SessionPersistenceProvider.loadCostHistory}. */
68
+ saveCostHistory?(scope: string, records: CostSessionRecord[]): Promise<void>;
69
+
46
70
  /**
47
71
  * Server-saved diagnostics (GENC-1351 §5.8) — a **forward-only append** stream
48
72
  * that lets the downloadable debug log span the whole session lifetime, not just
@@ -77,7 +77,14 @@ Suite('save → reload → restore brings back messages, selection, counters', a
77
77
  const restored = reduce(freshStore, loadSession(toLoadSessionPayload(snapshot!)));
78
78
  assert.is(restored.messages.length, 2);
79
79
  assert.is(restored.pinnedAgentName, 'Guided Booking');
80
- assert.is(restored.sessionCostUsd, 0.5);
80
+ // Cost comes from the restored transcript, NOT the snapshot's stored counter — so it
81
+ // is whatever the messages actually carry. Computed independently here rather than
82
+ // by calling the summing helper under test.
83
+ const expectedCost = restored.messages.reduce(
84
+ (sum, m) => sum + (m.cost ?? 0) + (m.externalCostUsd ?? 0),
85
+ 0,
86
+ );
87
+ assert.is(restored.sessionUsage.costUsd, expectedCost);
81
88
  assert.is(restored.contextTokens, 800);
82
89
  assert.is(restored.contextLimit, 200000);
83
90
  assert.is(restored.activeModel, 'claude-sonnet-4-6');
@@ -101,7 +101,16 @@ function makePersister(over: Partial<Fakes> = {}): { p: SessionPersister; f: Fak
101
101
  flowActivationPrompt: null,
102
102
  activeAgent: undefined,
103
103
  pinnedAgentName: null,
104
- sessionCostUsd: 0,
104
+ // Inline rather than via `emptyUsage()` to keep this mock self-contained. The
105
+ // shape matters: the persister reads `sessionUsage.costUsd`, so a mock missing
106
+ // the object crashes the autosave timer rather than failing an assertion.
107
+ sessionUsage: {
108
+ costUsd: 0,
109
+ uncachedInputTokens: 0,
110
+ cacheReadTokens: 0,
111
+ cacheWriteTokens: 0,
112
+ outputTokens: 0,
113
+ },
105
114
  get restoring() {
106
115
  return f.restoring;
107
116
  },
@@ -282,7 +282,7 @@ export class SessionPersister {
282
282
  activeAgentName: s.activeAgent?.name,
283
283
  pinnedAgentName: s.pinnedAgentName,
284
284
  flowOwnerAgentName: null,
285
- sessionCostUsd: s.sessionCostUsd,
285
+ sessionCostUsd: s.sessionUsage.costUsd,
286
286
  contextTokens: s.contextTokens,
287
287
  contextLimit: s.contextLimit,
288
288
  activeModel: s.activeModel,
@@ -316,7 +316,7 @@ export class SessionPersister {
316
316
  activeAgentName: s.activeAgent?.name,
317
317
  pinnedAgentName: s.pinnedAgentName,
318
318
  flowOwnerAgentName: s.flowOwnerAgentName,
319
- sessionCostUsd: s.sessionCostUsd,
319
+ sessionCostUsd: s.sessionUsage.costUsd,
320
320
  contextTokens: s.contextTokens,
321
321
  contextLimit: s.contextLimit,
322
322
  activeModel: s.activeModel,
@@ -74,7 +74,10 @@ Suite('toLoadSessionPayload projects only the redux-restorable fields', () => {
74
74
  const p = toLoadSessionPayload(base());
75
75
  assert.is(p.pinnedAgentName, 'A');
76
76
  assert.is(p.flowOwnerAgentName, null);
77
- assert.is(p.sessionCostUsd, 2);
77
+ // Cost is deliberately NOT projected — the slice re-derives it (and the token
78
+ // buckets) from `messages`, so a stored counter would be a second, divergeable
79
+ // source of truth.
80
+ assert.is((p as { sessionCostUsd?: number }).sessionCostUsd, undefined);
78
81
  assert.is(p.contextTokens, 10);
79
82
  assert.is(p.contextLimit, 100);
80
83
  assert.is(p.activeModel, 'm');
@@ -190,7 +190,11 @@ export function toLoadSessionPayload(s: PersistedSession): LoadSessionPayload {
190
190
  messages: s.messages,
191
191
  pinnedAgentName: s.pinnedAgentName ?? null,
192
192
  flowOwnerAgentName: s.flowOwnerAgentName ?? null,
193
- sessionCostUsd: s.sessionCostUsd ?? 0,
193
+ // `sessionCostUsd` is deliberately NOT projected: the slice re-derives cost and
194
+ // token totals from `messages` on load. The snapshot field is still written (its
195
+ // shape is public, and it is a useful cross-check in a saved blob), but it is no
196
+ // longer authoritative — a snapshot saved mid-reset could hold a zeroed total
197
+ // while its transcript still shows the spend.
194
198
  contextTokens: s.contextTokens,
195
199
  contextLimit: s.contextLimit,
196
200
  activeModel: s.activeModel,
@@ -81,7 +81,15 @@ function makeStoreFake(over: Record<string, unknown> = {}) {
81
81
  flowActivationPrompt: null as string | null,
82
82
  activeAgent: undefined as { name: string } | undefined,
83
83
  pinnedAgentName: null as string | null,
84
- sessionCostUsd: 0,
84
+ // The persister reads `sessionUsage.costUsd` when building a snapshot, so this
85
+ // has to be the object rather than a bare cost scalar.
86
+ sessionUsage: {
87
+ costUsd: 0,
88
+ uncachedInputTokens: 0,
89
+ cacheReadTokens: 0,
90
+ cacheWriteTokens: 0,
91
+ outputTokens: 0,
92
+ },
85
93
  contextTokens: 0,
86
94
  contextLimit: 0,
87
95
  activeModel: undefined,
@@ -0,0 +1,40 @@
1
+ import { css, type ElementStyles } from '@genesislcap/web-core';
2
+
3
+ /**
4
+ * Heading style for a settings-modal section, published so a host can title its own section.
5
+ *
6
+ * The assistant renders no heading for the `settings-app` slot — no text and no icon — because
7
+ * only the host knows what that section is. The consequence is that a host supplying one has to
8
+ * reproduce the styling of the built-in headings beside it, and a copied rule drifts the moment
9
+ * either side changes. So the declarations live here, are applied by the assistant's own
10
+ * headings, and are exported for the host to apply to its.
11
+ *
12
+ * Compose it into a component's styles and put `ai-settings-section-title` on the heading:
13
+ *
14
+ * ```ts
15
+ * import { assistantSettingsSectionTitleStyles } from '@genesislcap/ai-assistant';
16
+ *
17
+ * const styles = css`
18
+ * ${assistantSettingsSectionTitleStyles}
19
+ * :host { display: block; }
20
+ * `;
21
+ * ```
22
+ *
23
+ * Carries no margin on purpose: spacing belongs to the surrounding layout, which differs
24
+ * between the assistant's own sections and a slotted one, and is the single declaration hosts
25
+ * had to diverge on when they copied this rule.
26
+ *
27
+ * @beta
28
+ */
29
+ export const assistantSettingsSectionTitleStyles: ElementStyles = css`
30
+ .ai-settings-section-title {
31
+ display: flex;
32
+ align-items: center;
33
+ gap: calc(var(--design-unit) * 2px);
34
+ font-size: 11px;
35
+ font-weight: 600;
36
+ letter-spacing: 0.08em;
37
+ text-transform: uppercase;
38
+ color: var(--neutral-foreground-hint);
39
+ }
40
+ `;
@@ -1,23 +1,36 @@
1
1
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
2
  import type { CostSessionRecord } from './cost-session-history';
3
3
  import {
4
- appendCostSessionRecord,
5
4
  clearCostSessionHistory,
6
5
  costHistoryStorageKey,
7
6
  formatCostSessionDate,
8
7
  loadCostSessionHistory,
9
- removeCostSessionRecord,
8
+ resolveBankedUsage,
10
9
  saveCostSessionHistory,
10
+ sortRecordsByRecency,
11
+ upsertRecord,
11
12
  } from './cost-session-history';
13
+ import { emptyUsage } from './sum-usage';
12
14
 
13
15
  const scope = `test-${Date.now()}`;
14
16
 
17
+ const usage = (costUsd: number): CostSessionRecord['usage'] => ({
18
+ costUsd,
19
+ uncachedInputTokens: 100_000,
20
+ cacheReadTokens: 1_200_000,
21
+ cacheWriteTokens: 60_000,
22
+ outputTokens: 40_000,
23
+ });
24
+
15
25
  const sampleRecord = (overrides: Partial<CostSessionRecord> = {}): CostSessionRecord => ({
16
26
  id: 'rec-1',
27
+ projectKey: 'proj-1',
17
28
  title: 'Equity Options Pricer',
18
- endedAt: '2026-06-14T12:00:00.000Z',
19
- costUsd: 1.86,
20
- tokensConsumed: 1_400_000,
29
+ updatedAt: '2026-06-14T12:00:00.000Z',
30
+ usage: usage(1.86),
31
+ // Present by default so `delete record.banked` in the legacy-row cases actually removes
32
+ // something, and so the storage round-trip covers it.
33
+ banked: usage(0.5),
21
34
  models: [{ model: 'claude-sonnet-4-6', provider: 'anthropic' }],
22
35
  ...overrides,
23
36
  });
@@ -28,23 +41,90 @@ suite('uses a scoped storage key', () => {
28
41
  assert.is(costHistoryStorageKey('my-app'), 'genesis-ai-assistant:cost-history:my-app');
29
42
  });
30
43
 
31
- suite('persists and loads records newest-first via append', () => {
44
+ suite('upsertRecord prepends a project it has not seen', () => {
45
+ const first = upsertRecord([], sampleRecord({ id: 'a', projectKey: 'a', title: 'First' }));
46
+ const both = upsertRecord(first, sampleRecord({ id: 'b', projectKey: 'b', title: 'Second' }));
47
+ assert.is(both.length, 2);
48
+ // Insertion order, not recency — display ordering is sortRecordsByRecency's job.
49
+ assert.is(both[0]?.id, 'b', 'most recently added is prepended');
50
+ assert.is(both[1]?.id, 'a');
51
+ });
52
+
53
+ suite('upsertRecord replaces a project in place rather than stacking a second row', () => {
54
+ const seeded = [
55
+ sampleRecord({ id: 'b', projectKey: 'b', usage: usage(2) }),
56
+ sampleRecord({ id: 'a', projectKey: 'a', usage: usage(1) }),
57
+ ];
58
+ // Re-reporting a project must REPLACE its row. Appending here is what made a single
59
+ // project's spend count once per refresh in any total summed over the list.
60
+ const next = upsertRecord(seeded, sampleRecord({ id: 'a', projectKey: 'a', usage: usage(9.5) }));
61
+ assert.is(next.length, 2);
62
+ // Replaced in place rather than moved — the stored array is not a recency ordering.
63
+ assert.is(next[1]?.projectKey, 'a');
64
+ assert.is(next[1]?.usage.costUsd, 9.5);
65
+ assert.is(
66
+ next.reduce((sum, r) => sum + r.usage.costUsd, 0),
67
+ 11.5,
68
+ );
69
+ });
70
+
71
+ suite('round-trips records through storage', () => {
32
72
  clearCostSessionHistory(scope);
33
- appendCostSessionRecord(scope, sampleRecord({ id: 'a', title: 'First' }));
34
- appendCostSessionRecord(scope, sampleRecord({ id: 'b', title: 'Second' }));
73
+ const records = upsertRecord([], sampleRecord({ id: 'a', projectKey: 'a', usage: usage(3.5) }));
74
+ saveCostSessionHistory(scope, records);
35
75
  const loaded = loadCostSessionHistory(scope);
36
- assert.is(loaded.length, 2);
37
- assert.is(loaded[0]?.id, 'b');
38
- assert.is(loaded[1]?.id, 'a');
76
+ assert.is(loaded.length, 1);
77
+ assert.is(loaded[0]?.usage.costUsd, 3.5);
78
+ assert.is(loaded[0]?.usage.cacheReadTokens, 1_200_000, 'buckets survive serialization');
79
+ assert.is(loaded[0]?.banked?.costUsd, 0.5, 'banked survives serialization');
39
80
  clearCostSessionHistory(scope);
40
81
  });
41
82
 
42
- suite('removes one record by id', () => {
83
+ suite('discards legacy rows that predate the per-project key', () => {
43
84
  clearCostSessionHistory(scope);
44
- saveCostSessionHistory(scope, [sampleRecord({ id: 'keep' }), sampleRecord({ id: 'drop' })]);
45
- const remaining = removeCostSessionRecord(scope, 'drop');
46
- assert.is(remaining.length, 1);
47
- assert.is(remaining[0]?.id, 'keep');
85
+ const legacy = sampleRecord({ id: 'old' }) as Partial<CostSessionRecord>;
86
+ delete legacy.projectKey;
87
+ saveCostSessionHistory(scope, [legacy as CostSessionRecord, sampleRecord({ id: 'new' })]);
88
+ const loaded = loadCostSessionHistory(scope);
89
+ // A keyless row can never be matched to a project, so pooling it with the new rows
90
+ // would permanently double-count whichever project it came from.
91
+ assert.is(loaded.length, 1);
92
+ assert.is(loaded[0]?.id, 'new');
93
+ clearCostSessionHistory(scope);
94
+ });
95
+
96
+ suite('discards rows whose usage is not fully numeric', () => {
97
+ clearCostSessionHistory(scope);
98
+ const broken = sampleRecord({ id: 'broken' });
99
+ // A half-shaped `usage` is worse than a missing row: it sums as NaN and poisons every
100
+ // total on the Usage tab, with no clue where the NaN came from.
101
+ delete (broken.usage as Partial<CostSessionRecord['usage']>).outputTokens;
102
+ saveCostSessionHistory(scope, [broken, sampleRecord({ id: 'ok', projectKey: 'ok' })]);
103
+ const loaded = loadCostSessionHistory(scope);
104
+ assert.is(loaded.length, 1);
105
+ assert.is(loaded[0]?.id, 'ok');
106
+ clearCostSessionHistory(scope);
107
+ });
108
+
109
+ suite('discards rows whose banked figure is half-shaped', () => {
110
+ clearCostSessionHistory(scope);
111
+ const broken = sampleRecord({ id: 'broken', banked: { ...usage(2) } });
112
+ // A malformed `banked` poisons the lifetime total exactly as a malformed `usage` does,
113
+ // since the two are added together to produce it.
114
+ delete (broken.banked as Partial<CostSessionRecord['usage']>).cacheWriteTokens;
115
+ saveCostSessionHistory(scope, [broken, sampleRecord({ id: 'ok', projectKey: 'ok' })]);
116
+ const loaded = loadCostSessionHistory(scope);
117
+ assert.is(loaded.length, 1);
118
+ assert.is(loaded[0]?.id, 'ok');
119
+ clearCostSessionHistory(scope);
120
+ });
121
+
122
+ suite('keeps a row whose banked figure is absent', () => {
123
+ clearCostSessionHistory(scope);
124
+ const legacy = sampleRecord({ id: 'legacy' });
125
+ delete legacy.banked;
126
+ saveCostSessionHistory(scope, [legacy]);
127
+ assert.is(loadCostSessionHistory(scope).length, 1, 'absent is valid, unlike half-shaped');
48
128
  clearCostSessionHistory(scope);
49
129
  });
50
130
 
@@ -52,4 +132,95 @@ suite('formats session dates for display', () => {
52
132
  assert.is(formatCostSessionDate('2026-06-14T12:00:00.000Z'), 'Jun 14, 2026');
53
133
  });
54
134
 
135
+ suite('sortRecordsByRecency puts the most recently worked-on project first', () => {
136
+ const records = [
137
+ sampleRecord({ id: 'old', projectKey: 'old', updatedAt: '2026-01-01T00:00:00.000Z' }),
138
+ sampleRecord({ id: 'newest', projectKey: 'newest', updatedAt: '2026-08-04T09:00:00.000Z' }),
139
+ sampleRecord({ id: 'mid', projectKey: 'mid', updatedAt: '2026-05-01T00:00:00.000Z' }),
140
+ ];
141
+ const sorted = sortRecordsByRecency(records);
142
+ assert.equal(
143
+ sorted.map((r) => r.id),
144
+ ['newest', 'mid', 'old'],
145
+ );
146
+ // Read-time ordering, so the caller's array must be left alone.
147
+ assert.equal(
148
+ records.map((r) => r.id),
149
+ ['old', 'newest', 'mid'],
150
+ 'input not mutated',
151
+ );
152
+ });
153
+
154
+ suite('sortRecordsByRecency overrides insertion order, which upsert leaves alone', () => {
155
+ // The behaviour this fixes: revisiting a project replaces its row IN PLACE, so insertion order
156
+ // strands it wherever it was first seen. Display order must come from updatedAt instead.
157
+ let records = upsertRecord(
158
+ [],
159
+ sampleRecord({ id: 'a', projectKey: 'a', updatedAt: '2026-01-01T00:00:00.000Z' }),
160
+ );
161
+ records = upsertRecord(
162
+ records,
163
+ sampleRecord({ id: 'b', projectKey: 'b', updatedAt: '2026-02-01T00:00:00.000Z' }),
164
+ );
165
+ // Work on 'a' again — newer than 'b', but upsert keeps it in slot 1.
166
+ records = upsertRecord(
167
+ records,
168
+ sampleRecord({ id: 'a', projectKey: 'a', updatedAt: '2026-03-01T00:00:00.000Z' }),
169
+ );
170
+ assert.is(records[1]?.id, 'a', 'stored order is unchanged by the revisit');
171
+ assert.is(sortRecordsByRecency(records)[0]?.id, 'a', 'display order tracks the revisit');
172
+ });
173
+
174
+ suite('banks nothing for a project with no prior row', () => {
175
+ assert.equal(resolveBankedUsage(undefined, true), emptyUsage());
176
+ assert.equal(resolveBankedUsage(undefined, false), emptyUsage());
177
+ });
178
+
179
+ suite('carries the prior banked figure forward when the transcript is authoritative', () => {
180
+ // Restored transcript: it already re-proves everything after the row's own banked figure,
181
+ // so only that figure carries over. Taking `usage` here is the compounding double-count.
182
+ const prior = sampleRecord({ usage: usage(10), banked: { ...usage(4) } });
183
+ assert.is(resolveBankedUsage(prior, true).costUsd, 4);
184
+ });
185
+
186
+ suite('banks the whole prior total when the transcript starts empty', () => {
187
+ // Nothing on screen accounts for the previous total, so all of it must carry over or the
188
+ // next turn rewrites the row down to just that turn.
189
+ const prior = sampleRecord({ usage: usage(10), banked: { ...usage(4) } });
190
+ assert.is(resolveBankedUsage(prior, false).costUsd, 10);
191
+ });
192
+
193
+ suite('treats a row predating the banked field as nothing banked', () => {
194
+ const legacy = sampleRecord({ usage: usage(7) });
195
+ delete legacy.banked;
196
+ assert.equal(resolveBankedUsage(legacy, true), emptyUsage());
197
+ // Without persistence the legacy total still carries, so upgrading loses no spend.
198
+ assert.is(resolveBankedUsage(legacy, false).costUsd, 7);
199
+ });
200
+
201
+ suite('is stable across repeated reloads — the regression this guards', () => {
202
+ // Simulate the reload cycle: usage = banked + whatever the transcript proves. With a
203
+ // restored transcript (worth 6 on top of 4 banked) the row must land on 10 every time,
204
+ // not 10 → 14 → 18 as it did when a reload re-banked the restored history.
205
+ const transcript = 6;
206
+ let row = sampleRecord({ usage: usage(10), banked: { ...usage(4) } });
207
+ for (let reload = 0; reload < 3; reload += 1) {
208
+ const banked = resolveBankedUsage(row, true);
209
+ row = { ...row, banked, usage: { ...usage(banked.costUsd + transcript) } };
210
+ assert.is(row.usage.costUsd, 10, `reload ${reload}`);
211
+ }
212
+ });
213
+
214
+ suite('accumulates across reloads when the transcript does not persist', () => {
215
+ // The mirror case: each page-load starts from an empty transcript, so the row must grow by
216
+ // whatever that load spends rather than being overwritten by it.
217
+ let row = sampleRecord({ usage: usage(10), banked: { ...usage(4) } });
218
+ for (const spend of [2, 3]) {
219
+ const banked = resolveBankedUsage(row, false);
220
+ row = { ...row, banked, usage: { ...usage(banked.costUsd + spend) } };
221
+ }
222
+ // 10 banked, +2 → 12; then 12 banked, +3 → 15. Never decreases.
223
+ assert.is(row.usage.costUsd, 15);
224
+ });
225
+
55
226
  suite.run();