@genesislcap/ai-assistant 15.12.0 → 15.13.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 +245 -3
- package/dist/ai-assistant.d.ts +127 -7
- package/dist/chat-driver.cjs +79 -13
- package/dist/chat-driver.cjs.map +2 -2
- package/dist/chat-driver.mjs +76 -12
- package/dist/chat-driver.mjs.map +2 -2
- package/dist/custom-elements.json +452 -359
- package/dist/dts/chat-driver-node.d.ts +2 -2
- package/dist/dts/chat-driver-node.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +45 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts +2 -0
- package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +20 -1
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/state/debug-event-log.d.ts.map +1 -1
- package/dist/dts/state/persistence/diagnostics.d.ts +65 -8
- package/dist/dts/state/persistence/diagnostics.d.ts.map +1 -1
- package/dist/dts/state/persistence/index.d.ts +1 -1
- package/dist/dts/state/persistence/index.d.ts.map +1 -1
- package/dist/dts/state/persistence/session-persister.d.ts +4 -3
- package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
- package/dist/dts/utils/sum-usage.d.ts +20 -0
- package/dist/dts/utils/sum-usage.d.ts.map +1 -1
- package/dist/esm/chat-driver-node.js +12 -2
- package/dist/esm/components/chat-driver/chat-driver.js +60 -5
- package/dist/esm/components/chat-driver/chat-driver.turn-usage.test.js +268 -0
- package/dist/esm/main/main.js +53 -28
- package/dist/esm/state/debug-event-log.js +7 -2
- package/dist/esm/state/persistence/diagnostics.js +79 -16
- package/dist/esm/state/persistence/diagnostics.test.js +174 -1
- package/dist/esm/state/persistence/index.js +1 -1
- package/dist/esm/state/persistence/session-persister.js +13 -5
- package/dist/esm/state/persistence/session-persister.test.js +31 -0
- package/dist/esm/utils/sum-usage.js +43 -0
- package/dist/esm/utils/sum-usage.test.js +45 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +17 -17
- package/src/chat-driver-node.ts +12 -2
- package/src/components/chat-driver/chat-driver.ts +107 -6
- package/src/components/chat-driver/chat-driver.turn-usage.test.ts +362 -0
- package/src/main/main.ts +52 -23
- package/src/state/debug-event-log.ts +7 -2
- package/src/state/persistence/diagnostics.test.ts +208 -1
- package/src/state/persistence/diagnostics.ts +117 -15
- package/src/state/persistence/index.ts +1 -1
- package/src/state/persistence/session-persister.test.ts +37 -0
- package/src/state/persistence/session-persister.ts +13 -5
- package/src/utils/sum-usage.test.ts +52 -1
- package/src/utils/sum-usage.ts +45 -0
package/src/main/main.ts
CHANGED
|
@@ -88,7 +88,7 @@ import {
|
|
|
88
88
|
deleteDriver,
|
|
89
89
|
} from '../state/driver-registry';
|
|
90
90
|
import { buildTimelineEntries } from '../state/persistence/build-timeline-entries';
|
|
91
|
-
import { assembleDebugLog } from '../state/persistence/diagnostics';
|
|
91
|
+
import { assembleDebugLog, withFreshMetaSnapshot } from '../state/persistence/diagnostics';
|
|
92
92
|
import type { DebugLog, DiagnosticEntry } from '../state/persistence/diagnostics';
|
|
93
93
|
import {
|
|
94
94
|
deleteDiagnosticsCursorsFor,
|
|
@@ -4044,7 +4044,7 @@ export class FoundationAiAssistant extends GenesisElement {
|
|
|
4044
4044
|
);
|
|
4045
4045
|
}
|
|
4046
4046
|
|
|
4047
|
-
/** The live current-page debug log (`{ readme, timeline, meta }`). @public */
|
|
4047
|
+
/** The live current-page debug log (`{ readme, sessionUsage, timeline, meta }`). @public */
|
|
4048
4048
|
getDebugLog(): DebugLog {
|
|
4049
4049
|
return assembleDebugLog(this.buildDiagnosticEntries(), DEBUG_LOG_README);
|
|
4050
4050
|
}
|
|
@@ -4058,6 +4058,39 @@ export class FoundationAiAssistant extends GenesisElement {
|
|
|
4058
4058
|
* been appended to the persisted stream.
|
|
4059
4059
|
*/
|
|
4060
4060
|
private buildDiagnosticEntries(): DiagnosticEntry[] {
|
|
4061
|
+
const stateKey = this.getStateKey();
|
|
4062
|
+
|
|
4063
|
+
// The message/turn/event timeline entries — built by the shared, pure `buildTimelineEntries`
|
|
4064
|
+
// (the same helper a headless consumer uses to harvest its own log), from the driver's pull
|
|
4065
|
+
// surfaces. Prefer the driver's raw history (carries sub-agent traces) over the redux projection.
|
|
4066
|
+
const timelineEntries = buildTimelineEntries({
|
|
4067
|
+
turnSnapshots: this.driver?.getTurnSnapshots?.() ?? [],
|
|
4068
|
+
messages: this.driver?.getRawHistory?.() ?? this.messages,
|
|
4069
|
+
metaEvents: stateKey ? getMetaEvents(stateKey) : [],
|
|
4070
|
+
});
|
|
4071
|
+
|
|
4072
|
+
// Fold in any external diagnostics harvested from an out-of-band driver (e.g. a server-side
|
|
4073
|
+
// ChatDriver whose collated log an interaction widget returned on its result). They ride the
|
|
4074
|
+
// same download + persisted-diagnostics path; `assembleDebugLog` sorts the whole timeline by
|
|
4075
|
+
// timestamp so they interleave chronologically. (GENC-1461 unified diagnostics.)
|
|
4076
|
+
return [
|
|
4077
|
+
...timelineEntries,
|
|
4078
|
+
...(this.driver?.getExternalDiagnostics?.() ?? []),
|
|
4079
|
+
this.buildMetaSnapshot(),
|
|
4080
|
+
] as DiagnosticEntry[];
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4083
|
+
/**
|
|
4084
|
+
* The single `meta-snapshot` entry for right now — the export-time `meta` block
|
|
4085
|
+
* (agent summary, active prompt/state, context + cost) plus the `dedupSignature`
|
|
4086
|
+
* the forward-capture delta keys on.
|
|
4087
|
+
*
|
|
4088
|
+
* Separate from `buildDiagnosticEntries` because the download path needs a
|
|
4089
|
+
* FRESH one on its own: the persisted stream only re-appends this block when the
|
|
4090
|
+
* near-static config changes, so the newest stored snapshot's volatile half — the
|
|
4091
|
+
* `context` figures especially — is typically frozen at the session's first flush.
|
|
4092
|
+
*/
|
|
4093
|
+
private buildMetaSnapshot(): DiagnosticEntry {
|
|
4061
4094
|
const timestamp = new Date().toISOString().replace(/:/g, '-');
|
|
4062
4095
|
// Snapshot the live active agent from the DRIVER — the instance whose
|
|
4063
4096
|
// `onActivate` ran, so its `getDebugSnapshot` closure holds state.
|
|
@@ -4074,16 +4107,6 @@ export class FoundationAiAssistant extends GenesisElement {
|
|
|
4074
4107
|
this.contextTokens != null && this.contextLimit != null && this.contextLimit > 0
|
|
4075
4108
|
? Math.round((this.contextTokens / this.contextLimit) * 100)
|
|
4076
4109
|
: undefined;
|
|
4077
|
-
const stateKey = this.getStateKey();
|
|
4078
|
-
|
|
4079
|
-
// The message/turn/event timeline entries — built by the shared, pure `buildTimelineEntries`
|
|
4080
|
-
// (the same helper a headless consumer uses to harvest its own log), from the driver's pull
|
|
4081
|
-
// surfaces. Prefer the driver's raw history (carries sub-agent traces) over the redux projection.
|
|
4082
|
-
const timelineEntries = buildTimelineEntries({
|
|
4083
|
-
turnSnapshots: this.driver?.getTurnSnapshots?.() ?? [],
|
|
4084
|
-
messages: this.driver?.getRawHistory?.() ?? this.messages,
|
|
4085
|
-
metaEvents: stateKey ? getMetaEvents(stateKey) : [],
|
|
4086
|
-
});
|
|
4087
4110
|
|
|
4088
4111
|
// The export-time `meta` block, carried on a `meta-snapshot` entry so it lives
|
|
4089
4112
|
// in the same forward stream (the latest one wins on reassembly, and the
|
|
@@ -4149,6 +4172,8 @@ export class FoundationAiAssistant extends GenesisElement {
|
|
|
4149
4172
|
// instead of "only when the block actually changed". The volatile evolution is
|
|
4150
4173
|
// already in the timeline (turn snapshots + `context.updated` events), so the
|
|
4151
4174
|
// persister only needs a fresh meta-snapshot when the config/prompt changes.
|
|
4175
|
+
// Consequence for the download path: the newest STORED snapshot's context/cost
|
|
4176
|
+
// figures are stale, which is why `buildDownloadLog` appends a fresh one.
|
|
4152
4177
|
const m = metaSnapshot.meta as Record<string, unknown>;
|
|
4153
4178
|
metaSnapshot.dedupSignature = JSON.stringify({
|
|
4154
4179
|
host: m.host,
|
|
@@ -4157,16 +4182,7 @@ export class FoundationAiAssistant extends GenesisElement {
|
|
|
4157
4182
|
activePrimerHistory: m.activePrimerHistory,
|
|
4158
4183
|
activeFoldStack: m.activeFoldStack,
|
|
4159
4184
|
});
|
|
4160
|
-
|
|
4161
|
-
// Fold in any external diagnostics harvested from an out-of-band driver (e.g. a server-side
|
|
4162
|
-
// ChatDriver whose collated log an interaction widget returned on its result). They ride the
|
|
4163
|
-
// same download + persisted-diagnostics path; `assembleDebugLog` sorts the whole timeline by
|
|
4164
|
-
// timestamp so they interleave chronologically. (GENC-1461 unified diagnostics.)
|
|
4165
|
-
return [
|
|
4166
|
-
...timelineEntries,
|
|
4167
|
-
...(this.driver?.getExternalDiagnostics?.() ?? []),
|
|
4168
|
-
metaSnapshot,
|
|
4169
|
-
] as DiagnosticEntry[];
|
|
4185
|
+
return metaSnapshot;
|
|
4170
4186
|
}
|
|
4171
4187
|
|
|
4172
4188
|
async downloadDebugLog(): Promise<void> {
|
|
@@ -4200,6 +4216,14 @@ export class FoundationAiAssistant extends GenesisElement {
|
|
|
4200
4216
|
* chat" — diagnostics are a forensic stream keyed on provider capability, not on
|
|
4201
4217
|
* whether the *chat* is remembered (like preferences). Falls back to the live
|
|
4202
4218
|
* current-page log when diagnostics isn't available or the fetch fails.
|
|
4219
|
+
*
|
|
4220
|
+
* The stored `meta-snapshot`s are replaced by a fresh one (`withFreshMetaSnapshot`)
|
|
4221
|
+
* before reassembly. The persisted stream only re-appends that block when the
|
|
4222
|
+
* near-static config signature changes (see `collectDiagnosticsDelta`), so on a session
|
|
4223
|
+
* whose config never changes the newest STORED snapshot is the first one — its
|
|
4224
|
+
* `context` half (session cost/usage, context tokens, live agent state) frozen seconds
|
|
4225
|
+
* into the session, which is how a lifetime log came out reporting near-zero spend
|
|
4226
|
+
* against a transcript full of priced messages.
|
|
4203
4227
|
*/
|
|
4204
4228
|
private async buildDownloadLog(): Promise<DebugLog> {
|
|
4205
4229
|
const provider = this.persistence.provider;
|
|
@@ -4209,7 +4233,12 @@ export class FoundationAiAssistant extends GenesisElement {
|
|
|
4209
4233
|
// Land the current page's unflushed delta first so the download includes it.
|
|
4210
4234
|
await this.persister()?.flushDiagnostics();
|
|
4211
4235
|
const stored = await provider.loadDiagnostics(key);
|
|
4212
|
-
if (stored.length)
|
|
4236
|
+
if (stored.length) {
|
|
4237
|
+
return assembleDebugLog(
|
|
4238
|
+
withFreshMetaSnapshot(stored, this.buildMetaSnapshot()),
|
|
4239
|
+
DEBUG_LOG_README,
|
|
4240
|
+
);
|
|
4241
|
+
}
|
|
4213
4242
|
} catch (e) {
|
|
4214
4243
|
logger.error('Diagnostics load failed — using current-page log:', e);
|
|
4215
4244
|
}
|
|
@@ -316,17 +316,22 @@ export function clearSession(key: string): void {
|
|
|
316
316
|
*/
|
|
317
317
|
export const DEBUG_LOG_README: readonly string[] = [
|
|
318
318
|
'This is an exported debug log for the Genesis AI assistant. Read it top-to-bottom.',
|
|
319
|
+
"`sessionUsage` is what this session spent: `costUsd` (USD, provider-reported per request and summed, cache discounts already applied) plus the four TOKEN BUCKETS — `uncachedInputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `outputTokens`. Those four are disjoint and safe to add up for a total-tokens figure; the per-message fields further down are NOT (see kind:'message'). Each bucket bills at a different rate — cache reads a fraction of uncached input, cache writes a premium, output highest — so a big token count against a small cost means the prompt was mostly cache hits, not a missing charge. It covers sub-agent turns and spend a compaction banked, so for the CONVERSATION it is the authoritative total: prefer it over re-summing the timeline, which can be short by whatever the ring buffers evicted. The same figures appear under `meta.context`. It is as current as the snapshot it came from: the assistant's own download stamps a fresh one, but a log stitched by another tool can carry an older one — compare `meta.timestamp` with the last timeline entry, and if it is well behind, read these totals as historical and fall back to summing the timeline. One scope caveat: it is derived from the transcript, so it excludes billed calls that produced no message (a blank or refused attempt that was retried — see kind:'turn'.`usage`). Summing the turns can therefore come out HIGHER than this, and the difference is exactly that discarded spend, not a double-count.",
|
|
320
|
+
"What 'session' means for those figures: the CURRENT conversation under this session key (a host-supplied per-project id, or the element id + header title when the host supplies none) — which is not necessarily everything in `timeline`. It is re-derived from the live transcript, so it spans page loads only when the chat is being remembered, and a Clear resets it to zero. `timeline` is append-only regardless: it keeps every page load and the pre-Clear conversation, with the session.cleared event as the boundary. So on a long-lived log, read these totals as the latest conversation's and the earlier sections as history — not as a total of the whole file.",
|
|
319
321
|
'`timeline` is the entire session as one array, already sorted chronologically by `timestamp` (ISO 8601). Every entry has a `kind`.',
|
|
320
322
|
'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.',
|
|
321
323
|
"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.",
|
|
322
324
|
"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.",
|
|
323
325
|
"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.",
|
|
326
|
+
"kind:'turn'.`model`/`providerName`/`provider` — which model ran that call: the concrete model id, the registry slot it resolved under (a tier name like 'high'/'low', or the default), and the vendor. Recorded per CALL, so an agent whose `provider` selector varies by state — a flow that plans on a high tier and executes on a low one — has every step attributed, including calls that produced no message. `model` is the SERVING model where the provider reports one, so a turn answered by a server-side fallback names the model that answered rather than the one requested; it always matches the `model` on the message that call produced. Absent when the provider exposes no `getStatus` and the transport stamped nothing.",
|
|
327
|
+
"kind:'turn'.`usage` — what that ONE call cost, in the same four-bucket + `costUsd` shape as `sessionUsage`. Absent while a call is in flight and on providers that report no usage. This is the SAME money as the message the call produced, not extra money: never add turn usage to message usage, and read a turn plus its message as one charge. Its distinct value is the calls that produced NO message — a blank or refused response is billed and then discarded before the retry, so the turn entry is the only record of that spend, and a turn with `usage` but no message after it is exactly that. `costUsd: 0` alongside a nonzero token count means the provider reported no price, not that the call was free.",
|
|
324
328
|
"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.",
|
|
325
329
|
"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'.",
|
|
326
330
|
"Each 'event' also has an `importance`: 'high' (failures/limits — turn.error, tool.failed, subagent.failed, file.read-failed, suggestions.failed, context.threshold-crossed), 'normal' (session flow — connects, turns, retries, handoffs, agent/provider changes, interactions, sub-agent start/complete), or 'low' (skippable UI/bookkeeping noise — panel.toggled, attachment.added, driver.wired/unwired, context.updated, context.condensed). To skim, ignore importance:'low'; to triage a failure, filter to importance:'high' then read the nearby messages and turns. A 'high' turn.error is often preceded by one or more 'normal' turn.retry events for the same reason — read them together to see how many attempts were made before bailing. 'message' and 'turn' entries carry no importance — they are the substance, always read them.",
|
|
327
|
-
'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations/response-truncated/refusal/budget-exhausted, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, for response-truncated the model + maxTokens + outputTokens + tools, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, for budget-exhausted the budgetUsd + spentUsd figures reported by the proxy plus the resolved vendor, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout/response_truncated/refusal/budget_exhausted; budget_exhausted is terminal for the PARENT turn too — the parent stops rather than calling the model again into the same wall), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (
|
|
331
|
+
'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations/response-truncated/refusal/budget-exhausted, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, for response-truncated the model + maxTokens + outputTokens + tools, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, for budget-exhausted the budgetUsd + spentUsd figures reported by the proxy plus the resolved vendor, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout/response_truncated/refusal/budget_exhausted; budget_exhausted is terminal for the PARENT turn too — the parent stops rather than calling the model again into the same wall), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (the resolved provider for the upcoming turns — detail.provider is the registry SLOT/tier name, detail.model the concrete model behind it and detail.vendor its vendor; emitted only when the slot CHANGES, so read the per-turn `model` for the model of any given call rather than assuming the nearest event still applies), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
|
|
328
332
|
'Sub-agent meta events: a sub-agent\'s own turn.retry/turn.error/tool.failed/tool.unresolved events are merged into this same timeline, tagged with `detail.subAgent` — a `"<parent> › <sub-agent>"` breadcrumb that composes when nested (e.g. `"UI Builder › Planner › Grounding"`) — and interleaved by their original timestamps within the subagent.started→completed/failed bracket. These are the per-attempt/per-failure signals that do NOT appear among the sub-agent\'s (hoisted) messages: a malformed/empty attempt that gets retried produces no message, and the stale-vs-hallucinated split and streak counts live only on the event. A sub-agent\'s high-volume, message-derivable events (turn.start/turn.end, provider.selected, context.updated) are intentionally NOT merged — read its hoisted messages for model/tokens/cost and turn-by-turn activity, and the bracketing subagent.* events for the run\'s span.',
|
|
329
|
-
"`meta` holds context captured at export time: agentSummary (full agent configs), context (active model,
|
|
333
|
+
"`meta` holds context captured at export time: agentSummary (full agent configs), context (active model, contextTokens/contextLimit/contextUsagePercent for the last call, and the session totals — sessionCostUsd, sessionTokensConsumed, and the four-bucket sessionUsage lifted to the top of this log), activeDebugSnapshot (the active agent's `getDebugSnapshot()` taken fresh at export — reflects state NOW, which may have advanced beyond the last turn's agentSnapshot), debug (optional host-supplied debug state), host, and the export timestamp.",
|
|
334
|
+
'Note the two different scopes in `meta.context`: `contextTokens` is the prompt size of the LAST call (against `contextLimit`, the model context window), while `sessionUsage`/`sessionTokensConsumed` are cumulative BILLED throughput. Every turn resends the conversation, so the cumulative figure counts each turn’s prompt again in the next turn’s and is expected to dwarf the context size — that is not double-counting.',
|
|
330
335
|
'To debug a failure: find the last turn.error or tool.failed, then read upward for the user message, the turn(s), and the agent/provider/state events that led into it.',
|
|
331
336
|
];
|
|
332
337
|
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
2
|
-
import {
|
|
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
|
|
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 }`
|
|
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.
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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';
|