@genesislcap/ai-assistant 15.4.1 → 15.6.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 +571 -82
- package/dist/ai-assistant.d.ts +336 -36
- package/dist/chat-driver.cjs +104 -26
- package/dist/chat-driver.cjs.map +3 -3
- package/dist/chat-driver.mjs +104 -26
- package/dist/chat-driver.mjs.map +3 -3
- package/dist/custom-elements.json +314 -36
- package/dist/dts/components/chat-driver/chat-driver.d.ts +12 -0
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
- package/dist/dts/components/settings-modal/settings-modal.template.d.ts +9 -2
- package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
- package/dist/dts/index.d.ts +1 -0
- package/dist/dts/index.d.ts.map +1 -1
- package/dist/dts/main/cost-session-banking.test.d.ts +2 -0
- package/dist/dts/main/cost-session-banking.test.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +184 -24
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/provider/assistant-app-settings.d.ts +30 -5
- package/dist/dts/provider/assistant-app-settings.d.ts.map +1 -1
- package/dist/dts/state/ai-assistant-slice.d.ts +11 -8
- package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
- package/dist/dts/state/persistence/session-persistence-provider.d.ts +23 -0
- package/dist/dts/state/persistence/session-persistence-provider.d.ts.map +1 -1
- package/dist/dts/state/persistence/session-snapshot.d.ts.map +1 -1
- package/dist/dts/state/session-store.d.ts +1 -2
- package/dist/dts/state/session-store.d.ts.map +1 -1
- package/dist/dts/styles/settings-section.d.ts +29 -0
- package/dist/dts/styles/settings-section.d.ts.map +1 -0
- package/dist/dts/utils/condense-history.d.ts +14 -0
- package/dist/dts/utils/condense-history.d.ts.map +1 -1
- package/dist/dts/utils/cost-session-history.d.ts +103 -12
- package/dist/dts/utils/cost-session-history.d.ts.map +1 -1
- package/dist/dts/utils/resolve-cost-history-config.d.ts +9 -3
- package/dist/dts/utils/resolve-cost-history-config.d.ts.map +1 -1
- package/dist/dts/utils/sum-costs.d.ts.map +1 -1
- package/dist/dts/utils/sum-tokens.d.ts +8 -8
- package/dist/dts/utils/sum-tokens.d.ts.map +1 -1
- package/dist/dts/utils/sum-usage.d.ts +59 -0
- package/dist/dts/utils/sum-usage.d.ts.map +1 -0
- package/dist/dts/utils/sum-usage.test.d.ts +2 -0
- package/dist/dts/utils/sum-usage.test.d.ts.map +1 -0
- package/dist/esm/components/chat-driver/chat-driver.js +9 -1
- package/dist/esm/components/settings-modal/settings-modal.styles.js +237 -18
- package/dist/esm/components/settings-modal/settings-modal.template.js +229 -73
- package/dist/esm/index.js +1 -0
- package/dist/esm/main/cost-session-banking.test.js +308 -0
- package/dist/esm/main/main.js +424 -71
- package/dist/esm/state/ai-assistant-slice.js +11 -8
- package/dist/esm/state/ai-assistant-slice.test.js +12 -5
- package/dist/esm/state/debug-event-log.js +2 -2
- package/dist/esm/state/persistence/session-persistence.integration.test.js +5 -1
- package/dist/esm/state/persistence/session-persister.js +2 -2
- package/dist/esm/state/persistence/session-persister.test.js +10 -1
- package/dist/esm/state/persistence/session-snapshot.js +6 -2
- package/dist/esm/state/persistence/session-snapshot.test.js +4 -1
- package/dist/esm/state/persistence/stateful-restore.e2e.test.js +10 -1
- package/dist/esm/styles/settings-section.js +39 -0
- package/dist/esm/utils/condense-history.js +18 -5
- package/dist/esm/utils/condense-history.test.js +67 -0
- package/dist/esm/utils/cost-session-history.js +92 -15
- package/dist/esm/utils/cost-session-history.test.js +155 -13
- package/dist/esm/utils/resolve-cost-history-config.js +2 -1
- package/dist/esm/utils/sum-costs.js +2 -13
- package/dist/esm/utils/sum-tokens.js +10 -27
- package/dist/esm/utils/sum-tokens.test.js +1 -5
- package/dist/esm/utils/sum-usage.js +123 -0
- package/dist/esm/utils/sum-usage.test.js +120 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +17 -17
- package/src/components/chat-driver/chat-driver.ts +21 -0
- package/src/components/settings-modal/settings-modal.styles.ts +237 -18
- package/src/components/settings-modal/settings-modal.template.ts +270 -81
- package/src/index.ts +1 -0
- package/src/main/cost-session-banking.test.ts +407 -0
- package/src/main/main.ts +433 -68
- package/src/provider/assistant-app-settings.ts +31 -5
- package/src/state/ai-assistant-slice.test.ts +12 -5
- package/src/state/ai-assistant-slice.ts +21 -13
- package/src/state/debug-event-log.ts +2 -2
- package/src/state/persistence/session-persistence-provider.ts +24 -0
- package/src/state/persistence/session-persistence.integration.test.ts +8 -1
- package/src/state/persistence/session-persister.test.ts +10 -1
- package/src/state/persistence/session-persister.ts +2 -2
- package/src/state/persistence/session-snapshot.test.ts +4 -1
- package/src/state/persistence/session-snapshot.ts +5 -1
- package/src/state/persistence/stateful-restore.e2e.test.ts +9 -1
- package/src/styles/settings-section.ts +40 -0
- package/src/utils/condense-history.test.ts +103 -0
- package/src/utils/condense-history.ts +33 -3
- package/src/utils/cost-session-history.test.ts +187 -16
- package/src/utils/cost-session-history.ts +142 -23
- package/src/utils/resolve-cost-history-config.ts +10 -3
- package/src/utils/sum-costs.ts +2 -9
- package/src/utils/sum-tokens.test.ts +1 -11
- package/src/utils/sum-tokens.ts +10 -26
- package/src/utils/sum-usage.test.ts +140 -0
- package/src/utils/sum-usage.ts +130 -0
|
@@ -21,16 +21,42 @@ export interface AssistantAppSettingsToggle {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
state.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
`;
|
|
@@ -44,12 +44,14 @@ const ctx = (
|
|
|
44
44
|
turn?: number;
|
|
45
45
|
activationEnded?: (a: number) => boolean;
|
|
46
46
|
phaseEnded?: (e: number) => boolean;
|
|
47
|
+
batchCalls?: number;
|
|
47
48
|
} = {},
|
|
48
49
|
): CondenseContext => ({
|
|
49
50
|
modelCall: o.modelCall ?? 0,
|
|
50
51
|
turn: o.turn ?? 0,
|
|
51
52
|
activationEnded: o.activationEnded ?? (() => false),
|
|
52
53
|
phaseEnded: o.phaseEnded ?? (() => false),
|
|
54
|
+
batchCalls: o.batchCalls,
|
|
53
55
|
});
|
|
54
56
|
|
|
55
57
|
const sink = () => {
|
|
@@ -434,3 +436,104 @@ suite('multi-trigger first-wins: the earliest-listed firing trigger is the reaso
|
|
|
434
436
|
});
|
|
435
437
|
|
|
436
438
|
suite.run();
|
|
439
|
+
|
|
440
|
+
// ── batched collapse (GENC-1476) ───────────────────────────────────────────
|
|
441
|
+
// Condensation rewrites history in place, so collapsing the instant a trigger fires
|
|
442
|
+
// breaks the prompt cache on nearly every call of a re-read loop. Batching holds the
|
|
443
|
+
// collapse set still between boundaries.
|
|
444
|
+
|
|
445
|
+
const batch = createLogicSuite('applyCondensation batching');
|
|
446
|
+
|
|
447
|
+
/** A re-read loop: `n` reads of the same path, each registered one model-call apart. */
|
|
448
|
+
const reReadLoop = (n: number) => {
|
|
449
|
+
const history: ChatMessage[] = [];
|
|
450
|
+
const policies = new Map<string, RegisteredCondensePolicy>();
|
|
451
|
+
for (let i = 1; i <= n; i += 1) {
|
|
452
|
+
history.push(asstCall(`r${i}`, 'vfs_read', { path: 'A.tsx' }), toolMsg(`r${i}`, big()));
|
|
453
|
+
policies.set(
|
|
454
|
+
`r${i}`,
|
|
455
|
+
reg({ on: { kind: 'superseded', by: 'A.tsx' }, response: 'pointer' }, { iteration: i }),
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
return { history, policies };
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
/** Result content of every tool message, in order. */
|
|
462
|
+
const results = (out: ChatMessage[]): string[] =>
|
|
463
|
+
out.filter((m) => m.role === 'tool').map((m) => m.toolResult?.content ?? '');
|
|
464
|
+
|
|
465
|
+
batch('without batching every superseded read collapses immediately', () => {
|
|
466
|
+
const { history, policies } = reReadLoop(4);
|
|
467
|
+
const out = results(applyCondensation(history, policies, ctx({ modelCall: 4 }), sink().on));
|
|
468
|
+
// Only the newest survives; the other three are already stubs.
|
|
469
|
+
assert.is(out.filter((c) => c.startsWith('[')).length, 3);
|
|
470
|
+
assert.is(out[3].length, big().length);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
batch('batching leaves the prefix untouched between boundaries', () => {
|
|
474
|
+
const { history, policies } = reReadLoop(4);
|
|
475
|
+
// Boundary is floor(4/10)*10 = 0, so no call is within the batch yet.
|
|
476
|
+
const out = results(
|
|
477
|
+
applyCondensation(history, policies, ctx({ modelCall: 4, batchCalls: 10 }), sink().on),
|
|
478
|
+
);
|
|
479
|
+
assert.equal(
|
|
480
|
+
out.map((c) => c.length),
|
|
481
|
+
[big().length, big().length, big().length, big().length],
|
|
482
|
+
);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
batch('the batch boundary collapses the backlog in one step', () => {
|
|
486
|
+
const { history, policies } = reReadLoop(12);
|
|
487
|
+
const before = results(
|
|
488
|
+
applyCondensation(history, policies, ctx({ modelCall: 9, batchCalls: 10 }), sink().on),
|
|
489
|
+
);
|
|
490
|
+
const after = results(
|
|
491
|
+
applyCondensation(history, policies, ctx({ modelCall: 10, batchCalls: 10 }), sink().on),
|
|
492
|
+
);
|
|
493
|
+
assert.is(before.filter((c) => c.startsWith('[')).length, 0);
|
|
494
|
+
// At the boundary, reads 1-9 collapse together; 10-12 are past it and stay full.
|
|
495
|
+
assert.is(after.filter((c) => c.startsWith('[')).length, 9);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
batch('a call past the boundary neither collapses nor supersedes an older one', () => {
|
|
499
|
+
// The regression this guards: excluding the new call as a SUPERSEDER but still letting
|
|
500
|
+
// it collapse would elide the very result the agent just fetched.
|
|
501
|
+
const { history, policies } = reReadLoop(2);
|
|
502
|
+
policies.set(
|
|
503
|
+
'r2',
|
|
504
|
+
reg({ on: { kind: 'superseded', by: 'A.tsx' }, response: 'pointer' }, { iteration: 7 }),
|
|
505
|
+
);
|
|
506
|
+
const out = results(
|
|
507
|
+
applyCondensation(history, policies, ctx({ modelCall: 7, batchCalls: 5 }), sink().on),
|
|
508
|
+
);
|
|
509
|
+
assert.is(out[1].length, big().length, 'the newest read must stay full');
|
|
510
|
+
assert.is(out[0].length, big().length, 'the older read must not collapse early either');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
batch('age is measured against the boundary, not the live clock', () => {
|
|
514
|
+
const history = [asstCall('g1', 'grep', {}), toolMsg('g1', big())];
|
|
515
|
+
const policies = new Map([
|
|
516
|
+
['g1', reg({ on: { kind: 'age', turns: 1 }, response: 'pointer' }, { iteration: 1 })],
|
|
517
|
+
]);
|
|
518
|
+
// Live clock says age 4 > 1 (would collapse); the boundary says 0, so it holds.
|
|
519
|
+
const held = results(
|
|
520
|
+
applyCondensation(history, policies, ctx({ modelCall: 5, batchCalls: 10 }), sink().on),
|
|
521
|
+
);
|
|
522
|
+
assert.is(held[0].length, big().length);
|
|
523
|
+
const collapsed = results(
|
|
524
|
+
applyCondensation(history, policies, ctx({ modelCall: 10, batchCalls: 10 }), sink().on),
|
|
525
|
+
);
|
|
526
|
+
assert.ok(collapsed[0].startsWith('['));
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
batch('batchCalls of 1, 0 or absent all keep the historical behaviour', () => {
|
|
530
|
+
for (const batchCalls of [1, 0, undefined]) {
|
|
531
|
+
const { history, policies } = reReadLoop(3);
|
|
532
|
+
const out = results(
|
|
533
|
+
applyCondensation(history, policies, ctx({ modelCall: 3, batchCalls }), sink().on),
|
|
534
|
+
);
|
|
535
|
+
assert.is(out.filter((c) => c.startsWith('[')).length, 2, `batchCalls=${batchCalls}`);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
batch.run();
|
|
@@ -75,6 +75,20 @@ export interface CondenseContext {
|
|
|
75
75
|
* `phaseEnd` clock. True once `endPhase()` has been called since the call.
|
|
76
76
|
*/
|
|
77
77
|
phaseEnded: (phaseEpoch: number) => boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Collapse in batches of this many model-calls instead of continuously. A positive
|
|
80
|
+
* integer; decimals are floored and anything below `1` becomes `1`. Omit (or `1`) to
|
|
81
|
+
* collapse as soon as a trigger fires, which is the historical behaviour.
|
|
82
|
+
*
|
|
83
|
+
* Condensation rewrites a payload *in place*, so the model-bound history is not
|
|
84
|
+
* append-only and every prompt-cache entry written before the rewrite is invalidated
|
|
85
|
+
* from that position on. Collapsing the moment each trigger fires therefore breaks the
|
|
86
|
+
* cache on nearly every call of a re-read loop — the case condensation exists to
|
|
87
|
+
* contain. Batching holds the collapse set still between boundaries so the prefix is
|
|
88
|
+
* append-only in between, trading up to `batchCalls` calls' worth of extra context for
|
|
89
|
+
* one cache break per batch instead of one per call.
|
|
90
|
+
*/
|
|
91
|
+
batchCalls?: number;
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
/**
|
|
@@ -193,6 +207,20 @@ export function applyCondensation(
|
|
|
193
207
|
// lookup. For each `superseded` key the LAST call in history order survives.
|
|
194
208
|
// The name lookup lets a tool message (which carries only an id) name its tool
|
|
195
209
|
// in stubs and events.
|
|
210
|
+
// Batch boundary: the collapse set is resolved as of this model-call and stays put
|
|
211
|
+
// until the next boundary, so the prefix is append-only in between. `batchCalls` of 1
|
|
212
|
+
// (the default) makes this `ctx.modelCall` — i.e. no batching.
|
|
213
|
+
const batchCalls = Math.max(1, Math.floor(ctx.batchCalls ?? 1));
|
|
214
|
+
const asOf =
|
|
215
|
+
batchCalls === 1 ? ctx.modelCall : Math.floor(ctx.modelCall / batchCalls) * batchCalls;
|
|
216
|
+
// A call made after the boundary is invisible to this pass in BOTH directions: it
|
|
217
|
+
// cannot supersede an older call (which would collapse the older payload early), and
|
|
218
|
+
// it cannot itself collapse (which would elide the very result just fetched). Unbatched,
|
|
219
|
+
// the boundary is the live clock and this gate is off entirely — a call registered on
|
|
220
|
+
// the current model-call is still eligible, as it always was.
|
|
221
|
+
const withinBatch = (entry: RegisteredCondensePolicy): boolean =>
|
|
222
|
+
batchCalls === 1 || entry.iteration <= asOf;
|
|
223
|
+
|
|
196
224
|
const latestByKey = new Map<string, string>();
|
|
197
225
|
const nameById = new Map<string, string>();
|
|
198
226
|
for (const msg of history) {
|
|
@@ -200,7 +228,7 @@ export function applyCondensation(
|
|
|
200
228
|
for (const tc of msg.toolCalls) {
|
|
201
229
|
nameById.set(tc.id, tc.name);
|
|
202
230
|
const entry = policies.get(tc.id);
|
|
203
|
-
if (!entry) continue;
|
|
231
|
+
if (!entry || !withinBatch(entry)) continue;
|
|
204
232
|
for (const t of triggersOf(entry)) {
|
|
205
233
|
if (t.kind === 'superseded') latestByKey.set(t.by, tc.id);
|
|
206
234
|
}
|
|
@@ -217,7 +245,7 @@ export function applyCondensation(
|
|
|
217
245
|
// past the last allowed view: turns:1 → seen once, then gone. Monotonic
|
|
218
246
|
// clock, so this holds across turn boundaries too.
|
|
219
247
|
case 'age':
|
|
220
|
-
return
|
|
248
|
+
return asOf - entry.iteration > trig.turns;
|
|
221
249
|
case 'turnEnd':
|
|
222
250
|
return ctx.turn > entry.turn;
|
|
223
251
|
case 'agentEnd':
|
|
@@ -238,7 +266,9 @@ export function applyCondensation(
|
|
|
238
266
|
toolCallId: string,
|
|
239
267
|
entry: RegisteredCondensePolicy,
|
|
240
268
|
): CondenseTrigger | undefined =>
|
|
241
|
-
|
|
269
|
+
withinBatch(entry)
|
|
270
|
+
? triggersOf(entry).find((t) => triggerFires(toolCallId, entry, t))
|
|
271
|
+
: undefined;
|
|
242
272
|
|
|
243
273
|
return history.map((msg) => {
|
|
244
274
|
// Tool-call ARGS live on the assistant message.
|