@genesislcap/ai-assistant 15.12.0 → 15.13.1

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 (50) hide show
  1. package/dist/ai-assistant.api.json +245 -3
  2. package/dist/ai-assistant.d.ts +127 -7
  3. package/dist/chat-driver.cjs +79 -13
  4. package/dist/chat-driver.cjs.map +2 -2
  5. package/dist/chat-driver.mjs +76 -12
  6. package/dist/chat-driver.mjs.map +2 -2
  7. package/dist/custom-elements.json +99 -6
  8. package/dist/dts/chat-driver-node.d.ts +2 -2
  9. package/dist/dts/chat-driver-node.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +45 -1
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts +2 -0
  13. package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts.map +1 -0
  14. package/dist/dts/main/main.d.ts +20 -1
  15. package/dist/dts/main/main.d.ts.map +1 -1
  16. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  17. package/dist/dts/state/persistence/diagnostics.d.ts +65 -8
  18. package/dist/dts/state/persistence/diagnostics.d.ts.map +1 -1
  19. package/dist/dts/state/persistence/index.d.ts +1 -1
  20. package/dist/dts/state/persistence/index.d.ts.map +1 -1
  21. package/dist/dts/state/persistence/session-persister.d.ts +4 -3
  22. package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
  23. package/dist/dts/utils/sum-usage.d.ts +20 -0
  24. package/dist/dts/utils/sum-usage.d.ts.map +1 -1
  25. package/dist/esm/chat-driver-node.js +12 -2
  26. package/dist/esm/components/chat-driver/chat-driver.js +60 -5
  27. package/dist/esm/components/chat-driver/chat-driver.turn-usage.test.js +268 -0
  28. package/dist/esm/main/main.js +53 -28
  29. package/dist/esm/state/debug-event-log.js +7 -2
  30. package/dist/esm/state/persistence/diagnostics.js +79 -16
  31. package/dist/esm/state/persistence/diagnostics.test.js +174 -1
  32. package/dist/esm/state/persistence/index.js +1 -1
  33. package/dist/esm/state/persistence/session-persister.js +13 -5
  34. package/dist/esm/state/persistence/session-persister.test.js +31 -0
  35. package/dist/esm/utils/sum-usage.js +43 -0
  36. package/dist/esm/utils/sum-usage.test.js +45 -1
  37. package/dist/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +17 -17
  39. package/src/chat-driver-node.ts +12 -2
  40. package/src/components/chat-driver/chat-driver.ts +107 -6
  41. package/src/components/chat-driver/chat-driver.turn-usage.test.ts +362 -0
  42. package/src/main/main.ts +52 -23
  43. package/src/state/debug-event-log.ts +7 -2
  44. package/src/state/persistence/diagnostics.test.ts +208 -1
  45. package/src/state/persistence/diagnostics.ts +117 -15
  46. package/src/state/persistence/index.ts +1 -1
  47. package/src/state/persistence/session-persister.test.ts +37 -0
  48. package/src/state/persistence/session-persister.ts +13 -5
  49. package/src/utils/sum-usage.test.ts +52 -1
  50. package/src/utils/sum-usage.ts +45 -0
@@ -1,5 +1,10 @@
1
1
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
- import { assembleDebugLog, diagnosticsMessageKey, type DiagnosticEntry } from './diagnostics';
2
+ import {
3
+ assembleDebugLog,
4
+ diagnosticsMessageKey,
5
+ type DiagnosticEntry,
6
+ withFreshMetaSnapshot,
7
+ } from './diagnostics';
3
8
 
4
9
  // GENC-1351 §5.8: `assembleDebugLog` reassembles the forward-only diagnostics
5
10
  // stream back into the `{ readme, timeline, meta }` debug-log shape — timeline
@@ -92,8 +97,210 @@ Suite('collapses exact-duplicate entries but keeps genuinely-distinct ones', ()
92
97
  );
93
98
  });
94
99
 
100
+ Suite('lifts the newest snapshot’s session usage to the top of the log', () => {
101
+ // The four buckets + USD are the headline of a cost investigation, and `meta` is a
102
+ // large block to go digging in — so they surface at the top level. Same object, not a
103
+ // second derivation, and taken from the SAME snapshot that becomes `meta`.
104
+ const usage = {
105
+ costUsd: 1.25,
106
+ uncachedInputTokens: 1000,
107
+ cacheReadTokens: 9000,
108
+ cacheWriteTokens: 500,
109
+ outputTokens: 300,
110
+ };
111
+ const entries: DiagnosticEntry[] = [
112
+ {
113
+ kind: 'meta-snapshot',
114
+ timestamp: '2026-01-01T00:00:01.000Z',
115
+ meta: { context: { sessionUsage: { ...usage, costUsd: 0.01 } } },
116
+ },
117
+ {
118
+ kind: 'meta-snapshot',
119
+ timestamp: '2026-01-01T00:00:09.000Z',
120
+ meta: { context: { sessionUsage: usage } },
121
+ },
122
+ ];
123
+ assert.equal(assembleDebugLog(entries, README).sessionUsage, usage);
124
+ });
125
+
126
+ Suite('leaves session usage undefined when no snapshot carries it', () => {
127
+ // A stream with no meta-snapshot, or one written before the field existed. Absent
128
+ // rather than zeroed: "not recorded" must not read as "this session spent nothing".
129
+ assert.is(assembleDebugLog([], README).sessionUsage, undefined);
130
+ const noContext: DiagnosticEntry[] = [
131
+ { kind: 'meta-snapshot', timestamp: '2026-01-01T00:00:01.000Z', meta: { host: 'localhost' } },
132
+ ];
133
+ assert.is(assembleDebugLog(noContext, README).sessionUsage, undefined);
134
+ });
135
+
136
+ Suite('collapses the turn pair even when its rendered systemPrompt differs', () => {
137
+ // The pair is produced by two separate flushes, and `buildTimelineEntries` renders
138
+ // `systemPrompt` relative to the preceding snapshots — so if the snapshot holding the
139
+ // full prompt is evicted from the ring buffer between the two, the surviving turn
140
+ // renders its prompt in full where the earlier copy had the '<repeated>' marker. Any
141
+ // identity derived from the serialized entry breaks here and the log shows one model
142
+ // call as two turns, one unpriced. Identity is turnIndex + timestamp + agentName.
143
+ const unpriced: DiagnosticEntry = {
144
+ kind: 'turn',
145
+ turnIndex: '7',
146
+ timestamp: '2026-01-01T00:00:01.000Z',
147
+ agentName: 'Trade Operations',
148
+ systemPrompt: '<repeated — identical to turn 0>',
149
+ toolNames: [],
150
+ };
151
+ const priced: DiagnosticEntry = {
152
+ ...unpriced,
153
+ systemPrompt: 'the full prompt, now that turn 0 has been evicted',
154
+ usage: {
155
+ costUsd: 0.5,
156
+ uncachedInputTokens: 100,
157
+ cacheReadTokens: 0,
158
+ cacheWriteTokens: 0,
159
+ outputTokens: 20,
160
+ },
161
+ };
162
+ const turns = assembleDebugLog([unpriced, priced], README).timeline.filter(
163
+ (e) => e.kind === 'turn',
164
+ );
165
+ assert.is(turns.length, 1, 'still one model call');
166
+ assert.equal(turns[0].usage, priced.usage, 'and it is the priced copy');
167
+ });
168
+
169
+ Suite('keeps same-index turns from different loads and different sub-agents apart', () => {
170
+ // `turnIndex` restarts at '0' on every page load, so a lifetime log holds one turn '0'
171
+ // per load; two sub-agents invoked in one parent turn share the index prefix and are
172
+ // separated by `agentName` (see `forwardSubAgentSnapshots`).
173
+ const load1: DiagnosticEntry = {
174
+ kind: 'turn',
175
+ turnIndex: '0',
176
+ timestamp: '2026-01-01T00:00:01.000Z',
177
+ agentName: 'Booker',
178
+ toolNames: [],
179
+ };
180
+ const load2: DiagnosticEntry = { ...load1, timestamp: '2026-01-02T09:00:00.000Z' };
181
+ const sibling: DiagnosticEntry = { ...load1, turnIndex: '3-1', agentName: 'Planner' };
182
+ const cousin: DiagnosticEntry = { ...sibling, agentName: 'Grounding' };
183
+ const turns = assembleDebugLog([load1, load2, sibling, cousin], README).timeline.filter(
184
+ (e) => e.kind === 'turn',
185
+ );
186
+ assert.is(turns.length, 4, 'four distinct calls, none collapsed');
187
+ });
188
+
189
+ Suite('collapses the unpriced and priced copies of one turn onto the priced one', () => {
190
+ // A turn entry is persisted when it is created (before its model call) and again once
191
+ // `usage` is back-filled, so a stitched stream carries both. They are ONE model call
192
+ // and must read as one — with the cost, or the re-emit gained nothing.
193
+ const unpriced: DiagnosticEntry = {
194
+ kind: 'turn',
195
+ turnIndex: '0',
196
+ timestamp: '2026-01-01T00:00:01.000Z',
197
+ toolNames: [],
198
+ };
199
+ const priced: DiagnosticEntry = {
200
+ ...unpriced,
201
+ usage: {
202
+ costUsd: 0.5,
203
+ uncachedInputTokens: 100,
204
+ cacheReadTokens: 0,
205
+ cacheWriteTokens: 0,
206
+ outputTokens: 20,
207
+ },
208
+ };
209
+ for (const order of [
210
+ [unpriced, priced],
211
+ [priced, unpriced],
212
+ ]) {
213
+ const turns = assembleDebugLog(order, README).timeline.filter((e) => e.kind === 'turn');
214
+ assert.is(turns.length, 1, 'one turn, not two');
215
+ assert.equal(turns[0].usage, priced.usage, 'the priced copy wins regardless of stream order');
216
+ }
217
+ });
218
+
219
+ Suite('keeps two genuinely-different turns that differ only in usage', () => {
220
+ // The collapse keys on everything EXCEPT usage, so distinct calls stay distinct — they
221
+ // differ in timestamp (and turnIndex) whatever else they share.
222
+ const a: DiagnosticEntry = {
223
+ kind: 'turn',
224
+ turnIndex: '0',
225
+ timestamp: '2026-01-01T00:00:01.000Z',
226
+ toolNames: [],
227
+ };
228
+ const b: DiagnosticEntry = { ...a, turnIndex: '1', timestamp: '2026-01-01T00:00:02.000Z' };
229
+ const turns = assembleDebugLog([a, b], README).timeline.filter((e) => e.kind === 'turn');
230
+ assert.is(turns.length, 2);
231
+ });
232
+
95
233
  Suite.run();
96
234
 
235
+ // The download path's swap: a stored stream's `meta-snapshot`s replaced by an export-time
236
+ // one. This is the fix for a SILENT bug — the persisted stream only re-appends that block
237
+ // when the config signature changes, so a stitched lifetime log reported the totals frozen
238
+ // at the session's first flush (near-zero spend against a transcript full of priced
239
+ // messages) and nothing about it looked wrong.
240
+ const FreshSuite = createLogicSuite('withFreshMetaSnapshot');
241
+
242
+ const storedStream: DiagnosticEntry[] = [
243
+ {
244
+ kind: 'meta-snapshot',
245
+ timestamp: '2026-01-01T00:00:00.000Z',
246
+ meta: { context: { sessionUsage: { costUsd: 0.01 } } },
247
+ dedupSignature: 'cfg-1',
248
+ },
249
+ { kind: 'message', timestamp: '2026-01-01T00:00:01.000Z', role: 'user', content: 'hi' },
250
+ { kind: 'turn', turnIndex: '0', timestamp: '2026-01-01T00:00:02.000Z', toolNames: [] },
251
+ { kind: 'event', index: 0, timestamp: '2026-01-01T00:00:03.000Z', type: 'turn.end' },
252
+ ];
253
+
254
+ const freshSnapshot: DiagnosticEntry = {
255
+ kind: 'meta-snapshot',
256
+ timestamp: '2026-01-01T02:00:00.000Z',
257
+ meta: {
258
+ context: {
259
+ sessionUsage: {
260
+ costUsd: 1.25,
261
+ uncachedInputTokens: 1000,
262
+ cacheReadTokens: 9000,
263
+ cacheWriteTokens: 500,
264
+ outputTokens: 300,
265
+ },
266
+ },
267
+ },
268
+ dedupSignature: 'cfg-1',
269
+ };
270
+
271
+ FreshSuite('reports the export-time totals, not the frozen stored ones', () => {
272
+ const log = assembleDebugLog(withFreshMetaSnapshot(storedStream, freshSnapshot), README);
273
+ assert.is(log.sessionUsage?.costUsd, 1.25, 'the fresh snapshot supplies the totals');
274
+ assert.is(log.sessionUsage?.cacheReadTokens, 9000);
275
+ assert.equal(log.meta, freshSnapshot.meta, 'and the whole meta block is the fresh one');
276
+ });
277
+
278
+ FreshSuite('keeps every non-snapshot entry', () => {
279
+ const timeline = assembleDebugLog(
280
+ withFreshMetaSnapshot(storedStream, freshSnapshot),
281
+ README,
282
+ ).timeline;
283
+ assert.equal(
284
+ timeline.map((e) => e.kind),
285
+ ['message', 'turn', 'event'],
286
+ 'the conversation is untouched — only the snapshots are swapped',
287
+ );
288
+ });
289
+
290
+ FreshSuite('wins even when the stored snapshot claims a later time', () => {
291
+ // A stream written on another machine can carry a skewed clock, and reassembly keeps
292
+ // whichever snapshot claims the later timestamp — so the stale one must be REMOVED
293
+ // rather than out-timestamped.
294
+ const skewed: DiagnosticEntry[] = [
295
+ { ...storedStream[0], timestamp: '2099-01-01T00:00:00.000Z' },
296
+ ...storedStream.slice(1),
297
+ ];
298
+ const log = assembleDebugLog(withFreshMetaSnapshot(skewed, freshSnapshot), README);
299
+ assert.is(log.sessionUsage?.costUsd, 1.25);
300
+ });
301
+
302
+ FreshSuite.run();
303
+
97
304
  // GENC-1461: the reasoning/narration/answer split (GENC-1411) emits several messages from ONE model
98
305
  // response, so they share `timestamp` + `role` + `subAgentOf`. The message identity key must still
99
306
  // tell them apart — otherwise the forward-capture dedup treats them as one and drops all but the
@@ -2,19 +2,21 @@
2
2
  * Server-saved diagnostics (GENC-1351 §5.8) — the forward-only stream that lets
3
3
  * the debug log persist across a whole session lifetime, not just the current
4
4
  * page load. This module owns the *pure* pieces: the entry type and the
5
- * reassembly back into the `{ readme, timeline, meta }` debug-log shape. The
6
- * element owns capture/cadence; the provider owns storage layout.
5
+ * reassembly back into the `{ readme, sessionUsage, timeline, meta }` debug-log
6
+ * shape. The element owns capture/cadence; the provider owns storage layout.
7
7
  *
8
8
  * @packageDocumentation
9
9
  */
10
10
 
11
+ import type { AggregateUsage } from '@genesislcap/foundation-ai';
12
+
11
13
  /**
12
14
  * One entry in the diagnostics stream — the debug-log timeline-entry union
13
15
  * (`message` / `turn` / `event`, as `getDebugLog()` builds) plus a point-in-time
14
16
  * **`meta-snapshot`** carrying the export-time `meta` block (agent summary,
15
17
  * context, active debug snapshot, …). Kept permissive: a provider stores and
16
18
  * returns these opaquely, and reassembly reads only `kind`, `timestamp` (for
17
- * ordering), and `meta` (on a snapshot).
19
+ * ordering), `usage` (on a turn) and `meta` (on a snapshot).
18
20
  *
19
21
  * @public
20
22
  */
@@ -24,6 +26,16 @@ export interface DiagnosticEntry {
24
26
  timestamp?: string;
25
27
  /** Present on `meta-snapshot` entries: the export-time `meta` block. */
26
28
  meta?: unknown;
29
+ /**
30
+ * Present on a `turn` entry once its model call has returned and reported usage —
31
+ * the four token buckets plus USD for that one call (`TurnSnapshot.usage`).
32
+ *
33
+ * Read by the forward-capture delta as well as by readers: the turn entry is
34
+ * created *before* the call, so a flush that lands mid-call persists it without
35
+ * usage, and the delta re-emits it once (see `collectDiagnosticsDelta`) so the
36
+ * priced copy reaches the stream. {@link assembleDebugLog} then collapses the pair.
37
+ */
38
+ usage?: AggregateUsage;
27
39
  /**
28
40
  * Present on `meta-snapshot` entries: a stable signature of the near-static config
29
41
  * (excludes volatile timestamp/context/debug-snapshot), used by the forward-capture
@@ -37,6 +49,27 @@ export interface DiagnosticEntry {
37
49
  /** The reassembled debug log — the exact shape `getDebugLog()` returns. @public */
38
50
  export interface DebugLog {
39
51
  readme: readonly string[];
52
+ /**
53
+ * Session usage — the four token buckets plus USD cost — lifted out of the newest
54
+ * `meta-snapshot`'s `meta.context.sessionUsage` so the headline spend figures sit at
55
+ * the top of an exported log rather than buried under the (large) `meta` block. The
56
+ * same object, not a second derivation.
57
+ *
58
+ * `undefined` when the stream carries no `meta-snapshot`, or one written before the
59
+ * field existed. Never re-derived from the timeline: the per-request costs the
60
+ * transports stamped are authoritative, and a ring-buffered timeline can have lost
61
+ * entries the total still legitimately counts.
62
+ *
63
+ * **As current as the snapshot it came from, which is not automatically "now".** The
64
+ * forward-capture delta only re-appends a `meta-snapshot` when the near-static config
65
+ * signature changes, so the newest STORED snapshot of a session whose config never
66
+ * changed is its first — with the totals frozen there. A caller reassembling a stored
67
+ * stream on its own therefore gets stale figures unless it appends an export-time
68
+ * snapshot first: see {@link withFreshMetaSnapshot}, which is what the assistant's own
69
+ * download path does. `meta.timestamp` against the newest timeline entry tells you
70
+ * which case you are holding.
71
+ */
72
+ sessionUsage?: AggregateUsage;
40
73
  timeline: DiagnosticEntry[];
41
74
  meta: unknown;
42
75
  }
@@ -65,6 +98,28 @@ export function diagnosticsMessageKey(entry: DiagnosticEntry): string | null {
65
98
  : null;
66
99
  }
67
100
 
101
+ /**
102
+ * Identity of a `turn` entry — `turnIndex::timestamp::agentName`.
103
+ *
104
+ * Deliberately NOT the serialized entry, which two copies of the same call do not always
105
+ * share. `buildTimelineEntries` renders `systemPrompt` *relative to the preceding
106
+ * snapshots* (collapsing it to `<repeated — identical to turn N>` when it matches the
107
+ * previous full prompt), so if the snapshot holding that full prompt is evicted from the
108
+ * ring buffer between the unpriced flush and the priced one, the surviving turn renders
109
+ * its prompt differently and the two serializations diverge. Both copies are in the
110
+ * stream by then, so nothing would collapse them — and the log whose purpose is per-call
111
+ * cost would show one model call as two turns, one of them unpriced.
112
+ *
113
+ * All three components are fixed when the snapshot is created and never re-derived.
114
+ * `turnIndex` alone is not enough: it restarts at `'0'` on each page load, and a lifetime
115
+ * log legitimately holds one turn `'0'` per load. `agentName` separates two sub-agents
116
+ * invoked in the same parent turn, which share a `turnIndex` prefix by construction (see
117
+ * `forwardSubAgentSnapshots`).
118
+ */
119
+ function turnIdentity(entry: DiagnosticEntry): string {
120
+ return `turn::${String(entry.turnIndex ?? '')}::${entry.timestamp ?? ''}::${String(entry.agentName ?? '')}`;
121
+ }
122
+
68
123
  // Tie-break co-timestamped entries by cause → call → output (event, turn, message),
69
124
  // matching the original in-place `getDebugLog` sort.
70
125
  const KIND_RANK: Record<string, number> = { event: 0, turn: 1, message: 2 };
@@ -73,16 +128,23 @@ const KIND_RANK: Record<string, number> = { event: 0, turn: 1, message: 2 };
73
128
  const UNKNOWN_KIND_RANK = 99;
74
129
 
75
130
  /**
76
- * Reassemble diagnostic entries into the `{ readme, timeline, meta }` debug-log
77
- * shape (GENC-1351 §5.8). `timeline` = the `message`/`turn`/`event` entries sorted
131
+ * Reassemble diagnostic entries into the `{ readme, sessionUsage, timeline, meta }`
132
+ * debug-log shape (GENC-1351 §5.8). `timeline` = the `message`/`turn`/`event` entries sorted
78
133
  * by ISO `timestamp` (kind-rank tie-break). `meta` = the **newest** `meta-snapshot`'s
79
134
  * block only — a "state at export" photo, matching the single-page log. The older
80
135
  * snapshots are intentionally dropped: the per-turn evolution they would show is
81
136
  * already in the timeline (`turn.agentSnapshot` + `context.updated` events), so
82
137
  * keeping them would just repeat the bulky, near-static `agentSummary`. `readme` =
83
- * the passed (current) constant. Pure — reused for both the live current-page log
84
- * and the reassembled lifetime log stitched from the persisted stream, so both
85
- * come out shape-identical.
138
+ * the passed (current) constant. `sessionUsage` = that same newest snapshot's
139
+ * `meta.context.sessionUsage`, lifted to the top level. Pure — reused for both the
140
+ * live current-page log and the reassembled lifetime log stitched from the persisted
141
+ * stream, so both come out shape-identical.
142
+ *
143
+ * NOTE for a caller stitching a STORED stream (a headless consumer harvesting its own
144
+ * lifetime log, say): the newest stored `meta-snapshot` is frozen at the last
145
+ * config-signature change, so `meta` and `sessionUsage` are as old as that unless you
146
+ * pass a fresh snapshot of your own — {@link withFreshMetaSnapshot} does exactly that,
147
+ * and the element's download path goes through it.
86
148
  */
87
149
  export function assembleDebugLog(entries: DiagnosticEntry[], readme: readonly string[]): DebugLog {
88
150
  // Safety net for exact-duplicate entries: two element instances sharing one
@@ -92,20 +154,31 @@ export function assembleDebugLog(entries: DiagnosticEntry[], readme: readonly st
92
154
  // (e.g. two real `assistant.connected` from two instances) differ in index /
93
155
  // placement and are kept. (The shared cursor store prevents most of this at
94
156
  // write time; this guards the read path regardless of what's in the stream.)
95
- const seen = new Set<string>();
157
+ //
158
+ // A `turn` is identified by `turnIndex` + `timestamp` + `agentName` instead (see
159
+ // `turnIdentity`), so the unpriced copy a mid-call flush persisted and the priced copy
160
+ // that follows it collapse into ONE turn — the priced one — rather than reading as two
161
+ // model calls.
162
+ const seenAt = new Map<string, number>();
96
163
  let latestMeta: DiagnosticEntry | undefined;
97
164
  const timeline: DiagnosticEntry[] = [];
98
165
  for (const entry of entries) {
99
- const id = JSON.stringify(entry);
100
- if (seen.has(id)) continue;
101
- seen.add(id);
102
166
  if (entry.kind === 'meta-snapshot') {
103
167
  if (!latestMeta || (entry.timestamp ?? '') >= (latestMeta.timestamp ?? '')) {
104
168
  latestMeta = entry;
105
169
  }
106
- } else {
107
- timeline.push(entry);
170
+ continue;
171
+ }
172
+ const id = entry.kind === 'turn' ? turnIdentity(entry) : JSON.stringify(entry);
173
+ const at = seenAt.get(id);
174
+ if (at !== undefined) {
175
+ // Same entry seen twice — keep the richer copy. `usage` on a turn is the only
176
+ // field that can arrive late, so it is the only upgrade there is to make.
177
+ if (entry.usage && !timeline[at].usage) timeline[at] = entry;
178
+ continue;
108
179
  }
180
+ seenAt.set(id, timeline.length);
181
+ timeline.push(entry);
109
182
  }
110
183
  timeline.sort((a, b) => {
111
184
  const ta = a.timestamp ?? '';
@@ -114,5 +187,34 @@ export function assembleDebugLog(entries: DiagnosticEntry[], readme: readonly st
114
187
  if (ta > tb) return 1;
115
188
  return (KIND_RANK[a.kind] ?? UNKNOWN_KIND_RANK) - (KIND_RANK[b.kind] ?? UNKNOWN_KIND_RANK);
116
189
  });
117
- return { readme, timeline, meta: latestMeta?.meta };
190
+ // The session totals live inside the newest snapshot's `meta`, which is `unknown` by
191
+ // design (a provider stores these opaquely) — so this narrows just the one path it
192
+ // reads rather than typing the whole block.
193
+ const context = (latestMeta?.meta as { context?: { sessionUsage?: AggregateUsage } } | undefined)
194
+ ?.context;
195
+ return { readme, sessionUsage: context?.sessionUsage, timeline, meta: latestMeta?.meta };
196
+ }
197
+
198
+ /**
199
+ * A stored diagnostics stream with its `meta-snapshot`s replaced by `fresh` — what to
200
+ * pass {@link assembleDebugLog} when reassembling a persisted stream for export.
201
+ *
202
+ * The stored snapshots are dropped rather than out-timestamped. `assembleDebugLog` keeps
203
+ * whichever claims the later time, and a stream written on another machine can carry a
204
+ * skewed clock; `fresh` is built from live state and so is the more current by
205
+ * construction. Only one snapshot ever survives reassembly, so the count is unchanged —
206
+ * though a snapshot taken while the driver is unwired (mid-popout) can leave
207
+ * `activeFoldStack`/`activeDebugSnapshot` unset where a stored one had them. The figures
208
+ * this exists for — the context block and `sessionUsage` — read the session store and
209
+ * element props, which survive an unwire.
210
+ *
211
+ * Pure, and separate from the element for that reason: the swap it performs is the fix
212
+ * for a silent bug (a lifetime log reporting near-zero spend against a transcript full of
213
+ * priced messages), so it needs to be testable without mounting anything.
214
+ */
215
+ export function withFreshMetaSnapshot(
216
+ stored: readonly DiagnosticEntry[],
217
+ fresh: DiagnosticEntry,
218
+ ): DiagnosticEntry[] {
219
+ return [...stored.filter((e) => e.kind !== 'meta-snapshot'), fresh];
118
220
  }
@@ -6,5 +6,5 @@ export type {
6
6
  SessionPersistenceConfig,
7
7
  SessionPreferences,
8
8
  } from './session-persistence-provider';
9
- export { assembleDebugLog } from './diagnostics';
9
+ export { assembleDebugLog, withFreshMetaSnapshot } from './diagnostics';
10
10
  export type { DiagnosticEntry, DebugLog } from './diagnostics';
@@ -465,6 +465,43 @@ Suite(
465
465
  },
466
466
  );
467
467
 
468
+ Suite('flushDiagnostics() re-emits a turn once its per-call usage lands', async () => {
469
+ // A turn entry is created BEFORE its model call and priced when the response returns,
470
+ // so a flush landing in between persists it unpriced. Emitting it exactly once more —
471
+ // when `usage` is there — is what keeps per-call cost in a stitched lifetime log;
472
+ // `assembleDebugLog` collapses the pair back onto the priced copy.
473
+ const inFlight: DiagnosticEntry = {
474
+ kind: 'turn',
475
+ turnIndex: '0',
476
+ timestamp: '2026-01-01T00:00:01.000Z',
477
+ toolNames: [],
478
+ };
479
+ const { p, f } = makePersister({ diagnosticEntries: [inFlight] });
480
+ await p.flushDiagnostics();
481
+ assert.is(f.appended.length, 1, 'the unpriced turn is captured');
482
+ assert.is(f.appended[0][0].usage, undefined);
483
+
484
+ const priced: DiagnosticEntry = {
485
+ ...inFlight,
486
+ usage: {
487
+ costUsd: 0.25,
488
+ uncachedInputTokens: 100,
489
+ cacheReadTokens: 900,
490
+ cacheWriteTokens: 0,
491
+ outputTokens: 40,
492
+ },
493
+ };
494
+ f.diagnosticEntries = [priced];
495
+ await p.flushDiagnostics();
496
+ assert.is(f.appended.length, 2, 'the priced copy is appended');
497
+ assert.equal(f.appended[1][0].usage, priced.usage);
498
+
499
+ // ...and only once. A throttled flush runs on every transcript change, so a turn that
500
+ // re-emitted per flush would multiply the biggest entries in the stream.
501
+ await p.flushDiagnostics();
502
+ assert.is(f.appended.length, 2, 'no further re-append of the priced turn');
503
+ });
504
+
468
505
  Suite(
469
506
  'flushDiagnostics() still dedups a genuine re-append of the same message across flushes',
470
507
  async () => {
@@ -635,9 +635,10 @@ export class SessionPersister {
635
635
 
636
636
  /**
637
637
  * The diagnostic entries not yet appended this load — the forward-capture delta
638
- * (§5.8). Skips messages present at restore or already appended; emits each turn
639
- * (by turn-index) and meta-event (by index) once; appends a `meta-snapshot` only
640
- * when the block changed. Advances the shared per-session cursors as it goes.
638
+ * (§5.8). Skips messages present at restore or already appended; emits each meta-event
639
+ * (by index) once and each turn (by turn-index) once unpriced plus once more when its
640
+ * per-call `usage` lands; appends a `meta-snapshot` only when the block changed.
641
+ * Advances the shared per-session cursors as it goes.
641
642
  *
642
643
  * TODO(GENC-1461, longer-term): this whole delta step exists only because the element *pulls* the
643
644
  * ENTIRE debug log on every throttled flush (`getDiagnosticEntries()` returns the full timeline),
@@ -663,9 +664,16 @@ export class SessionPersister {
663
664
  }
664
665
  delta.push(entry);
665
666
  } else if (entry.kind === 'turn') {
667
+ // A turn entry is created BEFORE its model call and priced when the response
668
+ // lands, so a flush that falls in between persists it unpriced. Emit it a second
669
+ // time once `usage` is there — keyed separately so it happens at most once per
670
+ // turn — and let `assembleDebugLog` collapse the pair onto the priced copy.
671
+ // Without this the stitched lifetime log silently loses per-call cost for any
672
+ // turn that outlived a flush, which is most of the slow ones.
666
673
  const key = String(entry.turnIndex ?? '');
667
- if (cursors.emittedTurnKeys.has(key)) continue;
668
- cursors.emittedTurnKeys.add(key);
674
+ const emitKey = entry.usage ? `${key}::usage` : key;
675
+ if (cursors.emittedTurnKeys.has(emitKey)) continue;
676
+ cursors.emittedTurnKeys.add(emitKey);
669
677
  delta.push(entry);
670
678
  } else if (entry.kind === 'event') {
671
679
  const index = typeof entry.index === 'number' ? entry.index : -1;
@@ -1,6 +1,6 @@
1
1
  import type { ChatMessage } from '@genesislcap/foundation-ai';
2
2
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
- import { addUsage, emptyUsage, sumUsage, totalTokens } from './sum-usage';
3
+ import { addUsage, emptyUsage, messageUsage, sumUsage, totalTokens } from './sum-usage';
4
4
 
5
5
  const suite = createLogicSuite('sumUsage');
6
6
 
@@ -137,4 +137,55 @@ suite('addUsage with an empty operand is the identity', () => {
137
137
  assert.equal(addUsage(emptyUsage(), a), a);
138
138
  });
139
139
 
140
+ suite('messageUsage is undefined for a message reporting nothing', () => {
141
+ // A user turn, or a display-only reasoning/narration split. Distinct from a zeroed
142
+ // usage: "this call reported nothing" is not "this call cost nothing".
143
+ assert.is(messageUsage(msg({ role: 'user', content: 'hi' })), undefined);
144
+ assert.is(messageUsage(msg({ content: 'thinking…', category: 'reasoning' })), undefined);
145
+ });
146
+
147
+ suite('messageUsage agrees with sumUsage over the same single message', () => {
148
+ // The two derivations are deliberately separate — `accumulate` mutates one
149
+ // accumulator rather than allocating per message — so pin them together. A drift
150
+ // here would put one figure on a turn entry and a different one in the session
151
+ // total, both looking plausible.
152
+ const cases: Partial<ChatMessage>[] = [
153
+ { inputTokens: 1000, cacheReadTokens: 700, cacheWriteTokens: 200, outputTokens: 50, cost: 0.4 },
154
+ { inputTokens: 400, outputTokens: 60 },
155
+ { inputTokens: 100, cacheReadTokens: 90, cacheWriteTokens: 90 },
156
+ { cost: 0.01, externalCostUsd: 0.02 },
157
+ ];
158
+ for (const over of cases) {
159
+ const m = msg(over);
160
+ assert.equal(
161
+ messageUsage(m),
162
+ sumUsage([m]),
163
+ `messageUsage must match sumUsage for ${JSON.stringify(over)}`,
164
+ );
165
+ }
166
+ });
167
+
168
+ suite('messageUsage is shallow — it prices the one request, not the tree', () => {
169
+ // Deliberately blind to sub-agent traces and banked compaction usage: it answers
170
+ // "what did this call cost", which is what a turn snapshot needs. `sumUsage` is the
171
+ // function that walks everything underneath.
172
+ const m = msg({
173
+ inputTokens: 100,
174
+ outputTokens: 10,
175
+ cost: 0.05,
176
+ toolCalls: [
177
+ {
178
+ id: 't1',
179
+ name: 'delegate',
180
+ args: {},
181
+ subAgentTrace: [msg({ inputTokens: 900, outputTokens: 90, cost: 0.5 })],
182
+ },
183
+ ],
184
+ });
185
+ const usage = messageUsage(m)!;
186
+ assert.is(usage.costUsd, 0.05, 'the child conversation is not folded in');
187
+ assert.is(usage.uncachedInputTokens, 100);
188
+ assert.ok(sumUsage([m]).costUsd > usage.costUsd, 'sumUsage does fold it in');
189
+ });
190
+
140
191
  suite.run();
@@ -53,6 +53,51 @@ export function addUsage(a: AggregateUsage, b: AggregateUsage): AggregateUsage {
53
53
  };
54
54
  }
55
55
 
56
+ /**
57
+ * The four buckets plus USD for ONE message — the per-message fields projected into
58
+ * the disjoint, addable shape, or `undefined` when the message reports no usage at
59
+ * all (a user turn, a display-only reasoning/narration split, a provider that
60
+ * reports none).
61
+ *
62
+ * Shallow by design: it does NOT recurse into `toolCalls[].subAgentTrace` and does
63
+ * NOT count `compaction.rolledUpUsage`, so it answers "what did this one request
64
+ * cost" rather than "what did this message and everything under it cost". Use
65
+ * {@link sumUsage} for the latter — passing a whole transcript through this one
66
+ * message at a time would silently drop both.
67
+ *
68
+ * `costUsd` folds in `externalCostUsd` (non-LLM spend a widget reported), matching
69
+ * `sumUsage`. It is `0` when the provider priced nothing, which is NOT a claim that
70
+ * the call was free — see {@link AggregateUsage}; `UsageRow.costUsd` is the
71
+ * shape that keeps unpriced distinguishable, and is the right tool for a ledger.
72
+ *
73
+ * @beta
74
+ */
75
+ export function messageUsage(m: ChatMessage): AggregateUsage | undefined {
76
+ if (
77
+ m.cost == null &&
78
+ m.externalCostUsd == null &&
79
+ m.inputTokens == null &&
80
+ m.outputTokens == null &&
81
+ m.cacheReadTokens == null &&
82
+ m.cacheWriteTokens == null
83
+ ) {
84
+ return undefined;
85
+ }
86
+ const cacheReadTokens = m.cacheReadTokens ?? 0;
87
+ const cacheWriteTokens = m.cacheWriteTokens ?? 0;
88
+ return {
89
+ costUsd: (m.cost ?? 0) + (m.externalCostUsd ?? 0),
90
+ // The uncached REMAINDER, clamped — the same arithmetic `accumulate` below applies,
91
+ // for the same reasons. The two are deliberately separate implementations (that walk
92
+ // mutates one accumulator rather than allocating per message) and must agree;
93
+ // `sum-usage.test.ts` pins them together.
94
+ uncachedInputTokens: Math.max(0, (m.inputTokens ?? 0) - cacheReadTokens - cacheWriteTokens),
95
+ cacheReadTokens,
96
+ cacheWriteTokens,
97
+ outputTokens: m.outputTokens ?? 0,
98
+ };
99
+ }
100
+
56
101
  /**
57
102
  * Sum cost and per-bucket token usage across a message list.
58
103
  *