@genesislcap/ai-assistant 15.11.0 → 15.12.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 +135 -0
- package/dist/ai-assistant.d.ts +75 -3
- package/dist/chat-driver.cjs +540 -117
- package/dist/chat-driver.cjs.map +4 -4
- package/dist/chat-driver.mjs +524 -116
- package/dist/chat-driver.mjs.map +4 -4
- package/dist/custom-elements.json +1822 -1483
- package/dist/dts/chat-driver-node.d.ts +5 -2
- package/dist/dts/chat-driver-node.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +20 -3
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts +2 -0
- package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts.map +1 -0
- package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts +2 -0
- package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts.map +1 -0
- package/dist/dts/config/config.d.ts +39 -2
- package/dist/dts/config/config.d.ts.map +1 -1
- package/dist/dts/config/define-stateful-agent.d.ts +15 -1
- package/dist/dts/config/define-stateful-agent.d.ts.map +1 -1
- package/dist/dts/main/main.template.d.ts.map +1 -1
- package/dist/dts/utils/strip-agent-handlers.d.ts +1 -1
- package/dist/dts/utils/sum-usage.d.ts +37 -4
- package/dist/dts/utils/sum-usage.d.ts.map +1 -1
- package/dist/dts/utils/usage-rows.d.ts +102 -0
- package/dist/dts/utils/usage-rows.d.ts.map +1 -0
- package/dist/dts/utils/usage-rows.test.d.ts +2 -0
- package/dist/dts/utils/usage-rows.test.d.ts.map +1 -0
- package/dist/esm/chat-driver-node.js +36 -1
- package/dist/esm/components/chat-driver/chat-driver.js +73 -10
- package/dist/esm/components/chat-driver/chat-driver.thinking-policy.test.js +137 -0
- package/dist/esm/components/chat-driver/chat-driver.trace-capture.test.js +200 -0
- package/dist/esm/config/define-stateful-agent.js +11 -0
- package/dist/esm/main/main.template.js +20 -1
- package/dist/esm/utils/strip-agent-handlers.js +1 -1
- package/dist/esm/utils/sum-usage.js +37 -4
- package/dist/esm/utils/usage-rows.js +90 -0
- package/dist/esm/utils/usage-rows.test.js +189 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +17 -17
- package/src/chat-driver-node.ts +58 -0
- package/src/components/chat-driver/chat-driver.thinking-policy.test.ts +185 -0
- package/src/components/chat-driver/chat-driver.trace-capture.test.ts +251 -0
- package/src/components/chat-driver/chat-driver.ts +90 -10
- package/src/config/config.ts +50 -1
- package/src/config/define-stateful-agent.ts +37 -0
- package/src/main/main.template.ts +19 -1
- package/src/utils/strip-agent-handlers.ts +1 -1
- package/src/utils/sum-usage.ts +37 -4
- package/src/utils/usage-rows.test.ts +237 -0
- package/src/utils/usage-rows.ts +187 -0
|
@@ -584,6 +584,7 @@ export class ChatDriver extends EventTarget {
|
|
|
584
584
|
this.activeTemperatureInput = config.temperature;
|
|
585
585
|
this.activeToolChoiceInput = config.toolChoice;
|
|
586
586
|
this.activeCachePolicyInput = config.cachePolicy;
|
|
587
|
+
this.activeThinkingPolicyInput = config.thinkingPolicy;
|
|
587
588
|
this.activeTailContextInput = config.tailContext;
|
|
588
589
|
this.activeResponseSchemaInput = config.responseSchema;
|
|
589
590
|
this.activeFallbacks = config.fallbacks;
|
|
@@ -1403,15 +1404,29 @@ export class ChatDriver extends EventTarget {
|
|
|
1403
1404
|
* `condenseWhen` can register against the right call (it stamps the clocks
|
|
1404
1405
|
* straight off the driver). Absent for dispatch paths with no addressable tool
|
|
1405
1406
|
* call (e.g. the fold-close handler), where `condenseWhen` is a no-op.
|
|
1406
|
-
* @param traceCapture - Optional per-
|
|
1407
|
-
*
|
|
1408
|
-
* so parallel tool calls each capture their own
|
|
1407
|
+
* @param traceCapture - Optional per-tool-call accumulator. When provided, every
|
|
1408
|
+
* sub-agent call's trace is **appended** here rather than written to shared
|
|
1409
|
+
* instance state, so parallel tool calls each capture their own traces
|
|
1410
|
+
* independently.
|
|
1411
|
+
*
|
|
1412
|
+
* Deliberately a list of traces, not one slot. A single handler may call
|
|
1413
|
+
* `requestSubAgent` more than once — a code-driven scheduler dispatching a
|
|
1414
|
+
* dependency graph, a retry of a timed-out child, any fan-out helper — and a
|
|
1415
|
+
* single slot kept only the last, so every other child ran, was billed by the
|
|
1416
|
+
* provider, and then vanished from history. The loss was silent in the worst
|
|
1417
|
+
* way: `sumUsage` and `usageRows` both recurse into the trace, so they summed a
|
|
1418
|
+
* truncated input and still agreed with each other. Measured at 1 of 7 traces
|
|
1419
|
+
* kept on a seven-way fan-out, reporting ~2.3x under the real cost, with the
|
|
1420
|
+
* error growing as the fan-out widens.
|
|
1409
1421
|
*/
|
|
1410
1422
|
buildHandlerContext(activeToolCallId, traceCapture) {
|
|
1411
1423
|
return Object.assign(Object.assign({ requestInteraction: (componentName, data, options) => this.requestInteraction(componentName, data, options) }, (this.subAgentsMap.size > 0 && {
|
|
1412
1424
|
requestSubAgent: (name, options) => this.invokeSubAgent(name, options).then(({ outcome, trace }) => {
|
|
1413
|
-
|
|
1414
|
-
|
|
1425
|
+
// Append, never assign: see `traceCapture` on `buildHandlerContext`. Order
|
|
1426
|
+
// is completion order, which is fine — every message carries its own
|
|
1427
|
+
// timestamp and the timeline sorts on that.
|
|
1428
|
+
if (traceCapture && trace)
|
|
1429
|
+
traceCapture.traces.push(trace);
|
|
1415
1430
|
return outcome;
|
|
1416
1431
|
}),
|
|
1417
1432
|
})), { completeSubAgent: (result) => {
|
|
@@ -1908,6 +1923,18 @@ export class ChatDriver extends EventTarget {
|
|
|
1908
1923
|
// `iterations` because fold operations decrement iterations, which would incorrectly
|
|
1909
1924
|
// re-trigger the slice on subsequent calls after a fold open/close.
|
|
1910
1925
|
let firstLlmCall = !!currentInput;
|
|
1926
|
+
// Thinking posture for this WHOLE turn, resolved on the first model call and then held.
|
|
1927
|
+
//
|
|
1928
|
+
// Unlike temperature or toolChoice, this one cannot vary per iteration: a tool-use loop is
|
|
1929
|
+
// a single assistant turn, and Anthropic requires one thinking mode for its duration.
|
|
1930
|
+
// Toggling mid-loop does not error — the API silently disables thinking for that request
|
|
1931
|
+
// and strips blocks that would leave the turn structure invalid, so an `'auto' -> 'off'`
|
|
1932
|
+
// switch loses the reasoning continuity the opening call established while an
|
|
1933
|
+
// `'off' -> 'auto'` switch simply does not deliver the reasoning asked for. It also
|
|
1934
|
+
// invalidates the prompt cache, which costs more than the reasoning it was meant to save.
|
|
1935
|
+
// Resolved per USER turn instead, which is where the docs say to choose it.
|
|
1936
|
+
let pinnedThinkingPolicy;
|
|
1937
|
+
let thinkingPolicyPinned = false;
|
|
1911
1938
|
while (iterations < this.maxToolIterations) {
|
|
1912
1939
|
iterations += 1;
|
|
1913
1940
|
// Monotonic across the driver's life — the age clock. Unlike `iterations`,
|
|
@@ -2071,7 +2098,7 @@ export class ChatDriver extends EventTarget {
|
|
|
2071
2098
|
// provider is resolved — static value or a function of the turn context
|
|
2072
2099
|
// (which carries the live state for stateful agents). Resolved before the
|
|
2073
2100
|
// snapshot so the debug log records the exact request config the model saw.
|
|
2074
|
-
const [resolvedTemperature, resolvedToolChoice, resolvedCachePolicy, resolvedTailContext, resolvedResponseSchema,] =
|
|
2101
|
+
const [resolvedTemperature, resolvedToolChoice, resolvedCachePolicy, resolvedTailContext, resolvedResponseSchema, firstResolvedThinkingPolicy,] =
|
|
2075
2102
|
// oxlint-disable-next-line no-await-in-loop
|
|
2076
2103
|
yield Promise.all([
|
|
2077
2104
|
this.resolveTurnInput(this.activeTemperatureInput, promptCtx),
|
|
@@ -2079,7 +2106,16 @@ export class ChatDriver extends EventTarget {
|
|
|
2079
2106
|
this.resolveTurnInput(this.activeCachePolicyInput, promptCtx),
|
|
2080
2107
|
this.resolveTurnInput(this.activeTailContextInput, promptCtx),
|
|
2081
2108
|
this.resolveTurnInput(this.activeResponseSchemaInput, promptCtx),
|
|
2109
|
+
// Only consulted on the first iteration (see `pinnedThinkingPolicy`); resolved
|
|
2110
|
+
// alongside the others so a resolver still sees the same turn context.
|
|
2111
|
+
thinkingPolicyPinned
|
|
2112
|
+
? Promise.resolve(undefined)
|
|
2113
|
+
: this.resolveTurnInput(this.activeThinkingPolicyInput, promptCtx),
|
|
2082
2114
|
]);
|
|
2115
|
+
if (!thinkingPolicyPinned) {
|
|
2116
|
+
pinnedThinkingPolicy = firstResolvedThinkingPolicy;
|
|
2117
|
+
thinkingPolicyPinned = true;
|
|
2118
|
+
}
|
|
2083
2119
|
// The system prompt is always just the agent's resolved prompt — byte-stable, so it can be
|
|
2084
2120
|
// cached. The framework's volatile additions (fold suffix, retry nudge) and the agent's tail
|
|
2085
2121
|
// context all go to the framed tail: one uniform channel, no cache-scope branch. On a normal
|
|
@@ -2124,6 +2160,12 @@ export class ChatDriver extends EventTarget {
|
|
|
2124
2160
|
// Prompt-cache policy for this turn (Anthropic places breakpoints per scope; Gemini
|
|
2125
2161
|
// caches implicitly regardless). Undefined → no caching requested.
|
|
2126
2162
|
cachePolicy: resolvedCachePolicy,
|
|
2163
|
+
// Extended-thinking posture, pinned for the whole tool loop (one assistant turn) rather
|
|
2164
|
+
// than re-resolved per iteration — see `pinnedThinkingPolicy`. Undefined — unset, or the
|
|
2165
|
+
// resolver's answer for this turn — is NOT "off": it leaves the model on its own default,
|
|
2166
|
+
// so agents that never set this are priced exactly as before. Transports clamp models
|
|
2167
|
+
// that can't honour it.
|
|
2168
|
+
thinkingPolicy: pinnedThinkingPolicy,
|
|
2127
2169
|
// Framed volatile context injected at the message tail (never stored). Undefined → none.
|
|
2128
2170
|
tailContext,
|
|
2129
2171
|
// Structured-output schema for this turn (agent/state-resolved). When set, the transport
|
|
@@ -2303,8 +2345,15 @@ export class ChatDriver extends EventTarget {
|
|
|
2303
2345
|
// leaves the key off entirely rather than carrying it as `undefined`.
|
|
2304
2346
|
// JSON.stringify already drops undefined from the exported log, so this is
|
|
2305
2347
|
// chiefly about keeping the in-memory message shape honest.
|
|
2306
|
-
|
|
2348
|
+
// Fill, never overwrite — but still only when there is something to fill with.
|
|
2349
|
+
// A transport that already stamped a model knows something the driver does not:
|
|
2350
|
+
// `lastResolvedModel` is the model we ASKED for, so overwriting would relabel a
|
|
2351
|
+
// fallback-served turn as the requested model and misattribute its spend.
|
|
2352
|
+
// Written as a guard rather than `??=` because `??=` ASSIGNS undefined, which
|
|
2353
|
+
// would create the key and break the omit-when-unresolved contract above.
|
|
2354
|
+
if (response.model === undefined && this.lastResolvedModel !== undefined) {
|
|
2307
2355
|
response.model = this.lastResolvedModel;
|
|
2356
|
+
}
|
|
2308
2357
|
if (this.lastResolvedProvider !== undefined)
|
|
2309
2358
|
response.provider = this.lastResolvedProvider;
|
|
2310
2359
|
if (this.lastResolvedProviderName !== undefined) {
|
|
@@ -2517,15 +2566,26 @@ export class ChatDriver extends EventTarget {
|
|
|
2517
2566
|
}
|
|
2518
2567
|
return;
|
|
2519
2568
|
}
|
|
2520
|
-
// Real tool execution
|
|
2569
|
+
// Real tool execution.
|
|
2570
|
+
//
|
|
2571
|
+
// The accumulator is declared OUTSIDE the try so the catch can attach it
|
|
2572
|
+
// too: a handler that ran sub-agents and then threw (post-processing their
|
|
2573
|
+
// results failed, say) has already spent real money on children that
|
|
2574
|
+
// completed. Losing their traces on the error path would under-report the
|
|
2575
|
+
// run exactly as the single-slot bug did — and just as silently, since
|
|
2576
|
+
// `sumUsage` and `usageRows` would still agree with each other.
|
|
2577
|
+
const traceCapture = { traces: [] };
|
|
2578
|
+
const capturedTrace = () => traceCapture.traces.length ? traceCapture.traces.flat() : undefined;
|
|
2521
2579
|
try {
|
|
2522
|
-
const traceCapture = {};
|
|
2523
2580
|
const result = yield handler(tc.args, this.buildHandlerContext(tc.id, traceCapture));
|
|
2524
2581
|
const content = typeof result === 'string' ? result : JSON.stringify(result);
|
|
2525
2582
|
executedById.set(tc.id, {
|
|
2526
2583
|
toolCallId: tc.id,
|
|
2527
2584
|
content,
|
|
2528
|
-
|
|
2585
|
+
// Concatenated when a handler invoked several children, so none is lost.
|
|
2586
|
+
// Stays `undefined` when nothing was captured — readers key off presence,
|
|
2587
|
+
// and an empty array is a different claim from "no sub-agent ran".
|
|
2588
|
+
subAgentTrace: capturedTrace(),
|
|
2529
2589
|
});
|
|
2530
2590
|
anyRealToolExecuted = true;
|
|
2531
2591
|
}
|
|
@@ -2541,6 +2601,9 @@ export class ChatDriver extends EventTarget {
|
|
|
2541
2601
|
// Structured recovery hint so the model retries or routes around a tool
|
|
2542
2602
|
// failure instead of apologising and giving up.
|
|
2543
2603
|
content: `Tool error: ${e.message}\nRECOVERY: this tool failed once — you may retry it, or take a different valid action to make progress. Do NOT abandon the task, ask the user to rephrase, or claim you cannot make changes. If a planning tool failed, retry it or proceed with the information you already have.`,
|
|
2604
|
+
// Children that completed before the throw were still billed — keep
|
|
2605
|
+
// their traces so the run's cost stays whole.
|
|
2606
|
+
subAgentTrace: capturedTrace(),
|
|
2544
2607
|
});
|
|
2545
2608
|
anyRealToolExecuted = true; // treat errors as real work for fold op counting
|
|
2546
2609
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
3
|
+
// Side-effect import — MUST come before `./chat-driver` so the driver subclasses
|
|
4
|
+
// jsdom's EventTarget rather than Node's native one. Mirrors chat-driver.test.ts.
|
|
5
|
+
import './align-event-globals';
|
|
6
|
+
import { ChatDriver } from './chat-driver';
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Per-turn `thinkingPolicy` resolution.
|
|
9
|
+
//
|
|
10
|
+
// The cost case for this option is the SHAPE of a tool loop, not a single call: the
|
|
11
|
+
// opening turn is a real decision and worth reasoning over, while the iterations that
|
|
12
|
+
// follow mostly pick the next tool from a narrow set — and on a thinking model each of
|
|
13
|
+
// those bills reasoning at the full output rate. That saving only exists if the
|
|
14
|
+
// resolver runs per iteration, so these tests assert the sequence across a loop, not
|
|
15
|
+
// just that one value arrives.
|
|
16
|
+
//
|
|
17
|
+
// The other half is the undefined case. Every agent written before this option leaves
|
|
18
|
+
// it unset, and a resolver may answer `undefined` on any given turn; both must reach
|
|
19
|
+
// the transport as `undefined` so the model keeps its own default. `undefined` reaching
|
|
20
|
+
// the wire as `'off'` would be a silent capability regression, and as `'auto'` a silent
|
|
21
|
+
// bill increase — so it is asserted explicitly rather than assumed.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
/** Captures the policy seen on each model call; calls one tool, then finishes. */
|
|
24
|
+
const capturingProvider = () => {
|
|
25
|
+
const seen = [];
|
|
26
|
+
let turns = 0;
|
|
27
|
+
return {
|
|
28
|
+
seen,
|
|
29
|
+
chat: (_history, _userMessage, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
30
|
+
seen.push(options === null || options === void 0 ? void 0 : options.thinkingPolicy);
|
|
31
|
+
turns += 1;
|
|
32
|
+
// Two tool calls, so the loop runs three model calls in total — enough for a
|
|
33
|
+
// per-turn resolver to say something different on the later ones.
|
|
34
|
+
if (turns <= 2) {
|
|
35
|
+
return {
|
|
36
|
+
role: 'assistant',
|
|
37
|
+
content: '',
|
|
38
|
+
toolCalls: [{ id: `t${turns}`, name: 'step', args: {} }],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return { role: 'assistant', content: 'done' };
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
const makeRegistry = (provider) => ({
|
|
46
|
+
get: () => provider,
|
|
47
|
+
default: () => provider,
|
|
48
|
+
defaultName: () => 'test',
|
|
49
|
+
names: () => ['test'],
|
|
50
|
+
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
|
|
51
|
+
listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
|
|
52
|
+
});
|
|
53
|
+
const agent = (overrides) => (Object.assign({ name: 'worker', description: 'test agent', toolDefinitions: [
|
|
54
|
+
{ name: 'step', description: 'step', parameters: { type: 'object', properties: {} } },
|
|
55
|
+
], toolHandlers: { step: () => __awaiter(void 0, void 0, void 0, function* () { return 'stepped'; }) } }, overrides));
|
|
56
|
+
/** Run one user turn through a driver carrying `config`, and return the policies seen. */
|
|
57
|
+
const policiesFor = (config) => __awaiter(void 0, void 0, void 0, function* () {
|
|
58
|
+
const provider = capturingProvider();
|
|
59
|
+
const driver = new ChatDriver(makeRegistry(provider), {
|
|
60
|
+
maxToolIterations: 10,
|
|
61
|
+
maxFoldOperations: 5,
|
|
62
|
+
sessionKey: '',
|
|
63
|
+
});
|
|
64
|
+
driver.applyAgent(agent(config));
|
|
65
|
+
yield driver.sendMessage('go');
|
|
66
|
+
return provider.seen;
|
|
67
|
+
});
|
|
68
|
+
const suite = createLogicSuite('ChatDriver thinkingPolicy');
|
|
69
|
+
suite('leaves the policy undefined when the agent does not set one', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
70
|
+
// The compatibility case: every existing agent. Undefined must reach the transport
|
|
71
|
+
// as undefined so each model keeps its own default posture — not silently coerced
|
|
72
|
+
// to 'off' (a capability regression) or 'auto' (a bill increase).
|
|
73
|
+
const seen = yield policiesFor({});
|
|
74
|
+
assert.ok(seen.length >= 3, `expected a multi-call loop, got ${seen.length}`);
|
|
75
|
+
assert.equal(seen.filter((p) => p !== undefined), [], 'no turn invents a policy');
|
|
76
|
+
}));
|
|
77
|
+
suite('applies a static policy to every turn of the loop', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
78
|
+
const seen = yield policiesFor({ thinkingPolicy: 'off' });
|
|
79
|
+
assert.ok(seen.length >= 3);
|
|
80
|
+
assert.equal([...new Set(seen)], ['off'], 'a static value is not just a first-turn setting');
|
|
81
|
+
}));
|
|
82
|
+
suite('pins the resolved policy for the whole tool loop', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
83
|
+
// A tool-use loop is ONE assistant turn and Anthropic requires a single thinking mode
|
|
84
|
+
// for its duration. Toggling part-way does not error — the API silently disables
|
|
85
|
+
// thinking for that request and strips blocks that would leave the turn structure
|
|
86
|
+
// invalid, so an 'auto' -> 'off' switch loses the continuity the opening call
|
|
87
|
+
// established and 'off' -> 'auto' never delivers the reasoning asked for. It also
|
|
88
|
+
// invalidates the prompt cache, costing more than the reasoning it meant to save.
|
|
89
|
+
//
|
|
90
|
+
// So a resolver that changes its mind mid-loop must NOT be honoured mid-loop. This
|
|
91
|
+
// asserts the opposite of what it looks like it should: the later values are ignored.
|
|
92
|
+
let call = 0;
|
|
93
|
+
const seen = yield policiesFor({
|
|
94
|
+
thinkingPolicy: () => {
|
|
95
|
+
call += 1;
|
|
96
|
+
return call === 1 ? 'auto' : 'off';
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
assert.ok(seen.length >= 3, `expected a multi-call loop, got ${seen.length}`);
|
|
100
|
+
assert.equal([...new Set(seen)], ['auto'], 'the first call decides; later resolutions do not take effect until the next user turn');
|
|
101
|
+
}));
|
|
102
|
+
suite('re-resolves on the next user turn', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
103
|
+
// The flip side: pinning is per turn, not for the driver's lifetime, so a state change
|
|
104
|
+
// between turns still lands.
|
|
105
|
+
const provider = capturingProvider();
|
|
106
|
+
const driver = new ChatDriver(makeRegistry(provider), {
|
|
107
|
+
maxToolIterations: 10,
|
|
108
|
+
maxFoldOperations: 5,
|
|
109
|
+
sessionKey: '',
|
|
110
|
+
});
|
|
111
|
+
let turn = 0;
|
|
112
|
+
driver.applyAgent(agent({
|
|
113
|
+
thinkingPolicy: () => (turn === 0 ? 'auto' : 'off'),
|
|
114
|
+
}));
|
|
115
|
+
yield driver.sendMessage('go');
|
|
116
|
+
const firstTurn = [...provider.seen];
|
|
117
|
+
turn = 1;
|
|
118
|
+
yield driver.sendMessage('again');
|
|
119
|
+
const secondTurn = provider.seen.slice(firstTurn.length);
|
|
120
|
+
assert.equal([...new Set(firstTurn)], ['auto'], 'turn one holds its posture');
|
|
121
|
+
assert.equal([...new Set(secondTurn)], ['off'], 'turn two picks up the new one');
|
|
122
|
+
}));
|
|
123
|
+
suite('passes undefined through when the resolver declines to choose', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
124
|
+
// A resolver may answer on some turns and not others. `undefined` is its third answer —
|
|
125
|
+
// "leave this model alone" — and must not be normalised into a value. Asserted across
|
|
126
|
+
// the whole loop because the first call's answer is the one that gets pinned.
|
|
127
|
+
const seen = yield policiesFor({ thinkingPolicy: () => undefined });
|
|
128
|
+
assert.ok(seen.length >= 3);
|
|
129
|
+
assert.equal(seen.filter((p) => p !== undefined), [], 'declining is not the same as choosing');
|
|
130
|
+
}));
|
|
131
|
+
suite('awaits an async resolver', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
132
|
+
const seen = yield policiesFor({
|
|
133
|
+
thinkingPolicy: () => __awaiter(void 0, void 0, void 0, function* () { return 'off'; }),
|
|
134
|
+
});
|
|
135
|
+
assert.equal([...new Set(seen)], ['off'], 'a promise is resolved, not passed through');
|
|
136
|
+
}));
|
|
137
|
+
suite.run();
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
3
|
+
import { sumUsage } from '../../utils/sum-usage';
|
|
4
|
+
import { usageRows } from '../../utils/usage-rows';
|
|
5
|
+
// Side-effect import — MUST come before `./chat-driver` so the driver subclasses
|
|
6
|
+
// jsdom's EventTarget rather than Node's native one. Mirrors chat-driver.test.ts.
|
|
7
|
+
import './align-event-globals';
|
|
8
|
+
import { ChatDriver } from './chat-driver';
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Sub-agent trace capture when ONE tool call invokes SEVERAL sub-agents.
|
|
11
|
+
//
|
|
12
|
+
// The capture slot used to be a single `{ trace?: ChatMessage[] }` per tool call,
|
|
13
|
+
// assigned by each `requestSubAgent`. That covers N tool calls × 1 sub-agent — the
|
|
14
|
+
// case its docblock named — but not 1 tool call × N sub-agents, which is what a
|
|
15
|
+
// code-driven scheduler or a retry produces. All but the last trace was dropped, so
|
|
16
|
+
// those children ran, were billed, and then had no record in history.
|
|
17
|
+
//
|
|
18
|
+
// The failure was invisible to every existing check: `sumUsage` and `usageRows` both
|
|
19
|
+
// recurse into `subAgentTrace`, so they summed a truncated input and still reconciled
|
|
20
|
+
// with each other perfectly. Measured at 1 of 7 traces kept on a seven-way fan-out,
|
|
21
|
+
// ~2.3x under the true cost.
|
|
22
|
+
//
|
|
23
|
+
// These tests assert the traces SURVIVE, which is upstream of any cost assertion.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
/** Answers by rule: concurrent children under one turn make a FIFO queue nondeterministic. */
|
|
26
|
+
const ruleProvider = () => {
|
|
27
|
+
let parentTurns = 0;
|
|
28
|
+
return {
|
|
29
|
+
chat: (_history, _userMessage, options) => __awaiter(void 0, void 0, void 0, function* () {
|
|
30
|
+
var _a;
|
|
31
|
+
const tools = ((_a = options === null || options === void 0 ? void 0 : options.tools) !== null && _a !== void 0 ? _a : []).map((t) => t.name);
|
|
32
|
+
// Child turn: finishes via its completion tool, and reports usage so the
|
|
33
|
+
// reconciliation assertions have something to add up.
|
|
34
|
+
if (tools.includes('work')) {
|
|
35
|
+
return {
|
|
36
|
+
role: 'assistant',
|
|
37
|
+
content: '',
|
|
38
|
+
model: 'claude-sonnet-5',
|
|
39
|
+
cost: 0.01,
|
|
40
|
+
inputTokens: 100,
|
|
41
|
+
outputTokens: 20,
|
|
42
|
+
toolCalls: [{ id: 'w1', name: 'work', args: {} }],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (tools.includes('delegate') && parentTurns === 0) {
|
|
46
|
+
parentTurns += 1;
|
|
47
|
+
return {
|
|
48
|
+
role: 'assistant',
|
|
49
|
+
content: '',
|
|
50
|
+
model: 'claude-sonnet-5',
|
|
51
|
+
cost: 0.02,
|
|
52
|
+
inputTokens: 200,
|
|
53
|
+
outputTokens: 30,
|
|
54
|
+
toolCalls: [{ id: 'd0', name: 'delegate', args: {} }],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { role: 'assistant', content: 'done', model: 'claude-sonnet-5' };
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
const makeRegistry = (provider) => ({
|
|
62
|
+
get: () => provider,
|
|
63
|
+
default: () => provider,
|
|
64
|
+
defaultName: () => 'test',
|
|
65
|
+
names: () => ['test'],
|
|
66
|
+
getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
|
|
67
|
+
listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
|
|
68
|
+
});
|
|
69
|
+
const agent = (overrides) => (Object.assign({ description: 'test agent' }, overrides));
|
|
70
|
+
/** A child that reports one unit of usage and completes. */
|
|
71
|
+
const worker = (name) => agent({
|
|
72
|
+
name,
|
|
73
|
+
toolDefinitions: [
|
|
74
|
+
{ name: 'work', description: 'work', parameters: { type: 'object', properties: {} } },
|
|
75
|
+
],
|
|
76
|
+
toolHandlers: {
|
|
77
|
+
work: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
78
|
+
var _a;
|
|
79
|
+
(_a = ctx.completeSubAgent) === null || _a === void 0 ? void 0 : _a.call(ctx, { ok: true });
|
|
80
|
+
return 'worked';
|
|
81
|
+
}),
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
/**
|
|
85
|
+
* A parent whose single `delegate` tool call invokes `names` in turn — the shape a
|
|
86
|
+
* code-driven scheduler produces, and the one the old single slot truncated.
|
|
87
|
+
*/
|
|
88
|
+
const boss = (children, invoke) => agent({
|
|
89
|
+
name: 'boss',
|
|
90
|
+
subAgents: children,
|
|
91
|
+
toolDefinitions: [
|
|
92
|
+
{ name: 'delegate', description: 'delegate', parameters: { type: 'object', properties: {} } },
|
|
93
|
+
],
|
|
94
|
+
toolHandlers: {
|
|
95
|
+
delegate: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
96
|
+
yield Promise.all(invoke.map((n) => ctx.requestSubAgent(n, { task: 'go' })));
|
|
97
|
+
return 'delegated';
|
|
98
|
+
}),
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
const run = (config) => __awaiter(void 0, void 0, void 0, function* () {
|
|
102
|
+
const driver = new ChatDriver(makeRegistry(ruleProvider()), {
|
|
103
|
+
maxToolIterations: 20,
|
|
104
|
+
maxFoldOperations: 5,
|
|
105
|
+
sessionKey: '',
|
|
106
|
+
});
|
|
107
|
+
driver.applyAgent(config);
|
|
108
|
+
yield driver.sendMessage('go');
|
|
109
|
+
return driver.getHistory();
|
|
110
|
+
});
|
|
111
|
+
const tracesIn = (history) => history.flatMap((m) => { var _a; return ((_a = m.toolCalls) !== null && _a !== void 0 ? _a : []).flatMap((tc) => (tc.subAgentTrace ? [tc.subAgentTrace] : [])); });
|
|
112
|
+
const suite = createLogicSuite('ChatDriver sub-agent trace capture');
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
suite('keeps every trace when one tool call invokes three sub-agents', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
115
|
+
const names = ['gen_a', 'gen_b', 'gen_c'];
|
|
116
|
+
const history = yield run(boss(names.map(worker), names));
|
|
117
|
+
const traces = tracesIn(history);
|
|
118
|
+
assert.is(traces.length, 1, 'one tool call, so one concatenated trace');
|
|
119
|
+
// Each child contributes at least its own assistant turn. Before the fix this was
|
|
120
|
+
// one child's worth regardless of how many ran.
|
|
121
|
+
const seen = new Set(traces[0].map((m) => m.agentName).filter(Boolean));
|
|
122
|
+
assert.equal([...seen].sort(), names, `every invoked child must appear — got ${JSON.stringify([...seen])}`);
|
|
123
|
+
}));
|
|
124
|
+
suite('prices every child — usageRows emits a row per child and reconciles', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
125
|
+
// The reconciliation test extended to a fan-out. The single-child version passed
|
|
126
|
+
// before the fix AND after it, which is exactly why it did not catch this.
|
|
127
|
+
const names = ['gen_a', 'gen_b', 'gen_c'];
|
|
128
|
+
const history = yield run(boss(names.map(worker), names));
|
|
129
|
+
const rows = usageRows(history);
|
|
130
|
+
const childRows = rows.filter((r) => r.subAgentDepth === 1);
|
|
131
|
+
assert.is(childRows.length, names.length, 'one row per child that ran');
|
|
132
|
+
const rowTotal = rows.reduce((n, r) => { var _a, _b; return n + ((_a = r.costUsd) !== null && _a !== void 0 ? _a : 0) + ((_b = r.externalCostUsd) !== null && _b !== void 0 ? _b : 0); }, 0);
|
|
133
|
+
assert.ok(Math.abs(rowTotal - sumUsage(history).costUsd) < 1e-12, `rows ${rowTotal} vs sumUsage ${sumUsage(history).costUsd}`);
|
|
134
|
+
// Parent turn ($0.02) + three children ($0.01 each). Asserted as a number so a
|
|
135
|
+
// regression that silently drops a child fails here rather than only in the
|
|
136
|
+
// reconciliation above, which would still agree with a truncated input.
|
|
137
|
+
assert.ok(Math.abs(rowTotal - 0.05) < 1e-12, `expected 0.05, got ${rowTotal}`);
|
|
138
|
+
}));
|
|
139
|
+
suite('keeps both traces when the same sub-agent is invoked twice (a retry)', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
140
|
+
// The retry shape: one handler, one child name, two invocations. Under the old slot
|
|
141
|
+
// the first attempt's trace was overwritten by the second.
|
|
142
|
+
const history = yield run(boss([worker('gen_a')], ['gen_a', 'gen_a']));
|
|
143
|
+
const traces = tracesIn(history);
|
|
144
|
+
assert.is(traces.length, 1);
|
|
145
|
+
const childTurns = traces[0].filter((m) => m.agentName === 'gen_a' && m.cost != null);
|
|
146
|
+
assert.is(childTurns.length, 2, 'both attempts survive, not just the last');
|
|
147
|
+
const rows = usageRows(history).filter((r) => r.subAgentDepth === 1);
|
|
148
|
+
assert.is(rows.length, 2, 'and both are priced');
|
|
149
|
+
}));
|
|
150
|
+
suite('keeps child traces when the parent handler throws after they ran', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
151
|
+
// Children that completed were billed by the provider. If the error path drops
|
|
152
|
+
// their traces, the run under-reports exactly as the single-slot bug did — and
|
|
153
|
+
// just as silently, since `sumUsage` and `usageRows` would still agree with each
|
|
154
|
+
// other over the truncated input. PR review.
|
|
155
|
+
const names = ['gen_a', 'gen_b'];
|
|
156
|
+
const parent = agent({
|
|
157
|
+
name: 'boss',
|
|
158
|
+
subAgents: names.map(worker),
|
|
159
|
+
toolDefinitions: [
|
|
160
|
+
{ name: 'delegate', description: 'delegate', parameters: { type: 'object', properties: {} } },
|
|
161
|
+
],
|
|
162
|
+
toolHandlers: {
|
|
163
|
+
delegate: (_args, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
164
|
+
yield Promise.all(names.map((n) => ctx.requestSubAgent(n, { task: 'go' })));
|
|
165
|
+
throw new Error('post-processing the children failed');
|
|
166
|
+
}),
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
const history = yield run(parent);
|
|
170
|
+
const traces = tracesIn(history);
|
|
171
|
+
assert.is(traces.length, 1, 'the failed tool call still carries its trace');
|
|
172
|
+
const seen = [...new Set(traces[0].map((m) => m.agentName).filter(Boolean))].sort();
|
|
173
|
+
assert.equal(seen, names, 'both children survive the throw');
|
|
174
|
+
const childRows = usageRows(history).filter((r) => r.subAgentDepth === 1);
|
|
175
|
+
assert.is(childRows.length, 2, 'and both are still priced');
|
|
176
|
+
}));
|
|
177
|
+
suite('leaves subAgentTrace undefined when no sub-agent ran', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
178
|
+
var _a;
|
|
179
|
+
// Presence is the signal readers key off (`usage-rows.ts`, the UI's `when(...)`),
|
|
180
|
+
// so an empty array would be a different and wrong claim.
|
|
181
|
+
const history = yield run(agent({
|
|
182
|
+
name: 'boss',
|
|
183
|
+
subAgents: [worker('gen_a')],
|
|
184
|
+
toolDefinitions: [
|
|
185
|
+
{
|
|
186
|
+
name: 'delegate',
|
|
187
|
+
description: 'delegate',
|
|
188
|
+
parameters: { type: 'object', properties: {} },
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
toolHandlers: { delegate: () => __awaiter(void 0, void 0, void 0, function* () { return 'did it myself'; }) },
|
|
192
|
+
}));
|
|
193
|
+
for (const m of history) {
|
|
194
|
+
for (const tc of (_a = m.toolCalls) !== null && _a !== void 0 ? _a : []) {
|
|
195
|
+
assert.is(tc.subAgentTrace, undefined, 'not an empty array');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
assert.equal(usageRows(history).filter((r) => r.subAgentDepth === 1), [], 'no child rows');
|
|
199
|
+
}));
|
|
200
|
+
suite.run();
|
|
@@ -199,6 +199,16 @@ export function defineStatefulAgent(opts) {
|
|
|
199
199
|
return opts.cachePolicy(Object.assign(Object.assign({}, ctx), { state }));
|
|
200
200
|
})
|
|
201
201
|
: opts.cachePolicy;
|
|
202
|
+
// Unlike the resolvers above, a pre-init call returns `undefined` rather than throwing: that is
|
|
203
|
+
// this option's "model default" state, so degrading to it costs a turn its thinking preference
|
|
204
|
+
// instead of breaking the turn outright.
|
|
205
|
+
const wrappedThinkingPolicy = typeof opts.thinkingPolicy === 'function'
|
|
206
|
+
? (ctx) => __awaiter(this, void 0, void 0, function* () {
|
|
207
|
+
if (!state)
|
|
208
|
+
return undefined;
|
|
209
|
+
return opts.thinkingPolicy(Object.assign(Object.assign({}, ctx), { state }));
|
|
210
|
+
})
|
|
211
|
+
: opts.thinkingPolicy;
|
|
202
212
|
const wrappedTailContext = typeof opts.tailContext === 'function'
|
|
203
213
|
? (ctx) => __awaiter(this, void 0, void 0, function* () {
|
|
204
214
|
if (!state) {
|
|
@@ -249,6 +259,7 @@ export function defineStatefulAgent(opts) {
|
|
|
249
259
|
temperature: wrappedTemperature,
|
|
250
260
|
toolChoice: wrappedToolChoice,
|
|
251
261
|
cachePolicy: wrappedCachePolicy,
|
|
262
|
+
thinkingPolicy: wrappedThinkingPolicy,
|
|
252
263
|
tailContext: wrappedTailContext,
|
|
253
264
|
onUnresolvedTool: wrappedOnUnresolvedTool,
|
|
254
265
|
resumable: wrappedResumable,
|
|
@@ -144,11 +144,30 @@ const subAgentToolResultTemplate = html `
|
|
|
144
144
|
const subAgentMessageRowTemplate = html `
|
|
145
145
|
${when((m) => { var _a; return m.role === 'assistant' && !((_a = m.toolCalls) === null || _a === void 0 ? void 0 : _a.length) && !!m.content; }, subAgentAssistantTemplate)}${when((m) => { var _a; return !!((_a = m.toolCalls) === null || _a === void 0 ? void 0 : _a.length); }, subAgentToolCallTemplate)}${when((m) => { var _a; return m.role === 'tool' && !!((_a = m.toolResult) === null || _a === void 0 ? void 0 : _a.content); }, subAgentToolResultTemplate)}
|
|
146
146
|
`;
|
|
147
|
+
/**
|
|
148
|
+
* Label for a trace block: the distinct agents that appear in it.
|
|
149
|
+
*
|
|
150
|
+
* One tool call can invoke several sub-agents — a scheduler dispatching a dependency
|
|
151
|
+
* graph, or a retry — and their conversations arrive concatenated in one trace. Naming
|
|
152
|
+
* only `[0]` would then label a block "gen_A trace" while `gen_B`'s turns sit inside
|
|
153
|
+
* it. Two agents read as "gen_A, gen_B"; beyond that it degrades to a count rather
|
|
154
|
+
* than growing an unbounded summary line.
|
|
155
|
+
*/
|
|
156
|
+
const subAgentTraceLabel = (trace) => {
|
|
157
|
+
const names = [...new Set(trace.map((m) => m.agentName).filter(Boolean))];
|
|
158
|
+
if (names.length === 0)
|
|
159
|
+
return 'Sub-agent trace';
|
|
160
|
+
if (names.length <= 2)
|
|
161
|
+
return `${names.join(', ')} trace`;
|
|
162
|
+
// Owns the whole string rather than returning a fragment the template suffixes, so the noun
|
|
163
|
+
// agrees with the count — a shared ` trace` suffix rendered "3 sub-agents trace".
|
|
164
|
+
return `${names.length} sub-agent traces`;
|
|
165
|
+
};
|
|
147
166
|
/** Collapsed <details> trace shown inside a tool-call card once the sub-agent finishes. */
|
|
148
167
|
const subAgentTraceTemplate = html `
|
|
149
168
|
<details class="sub-agent-trace">
|
|
150
169
|
<summary class="sub-agent-trace-summary">
|
|
151
|
-
${(tc) =>
|
|
170
|
+
${(tc) => subAgentTraceLabel(tc.subAgentTrace)}
|
|
152
171
|
</summary>
|
|
153
172
|
${repeat((tc) => tc.subAgentTrace.filter((m) => m.role !== 'user'), subAgentMessageRowTemplate)}
|
|
154
173
|
</details>
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `onDeactivate`, `getDebugSnapshot`, `onUnresolvedTool`) and the function
|
|
7
7
|
* form of the per-turn resolvers (`systemPrompt`, `toolDefinitions`,
|
|
8
8
|
* `displayName`, `provider`, `temperature`, `toolChoice`, `cachePolicy`,
|
|
9
|
-
* `toolHandlers`).
|
|
9
|
+
* `thinkingPolicy`, `toolHandlers`).
|
|
10
10
|
* 2. **Object "handler bags" whose *values* are functions** — `toolHandlers` in
|
|
11
11
|
* its object form is `{ name: handler }`, so `typeof` is `'object'`, not
|
|
12
12
|
* `'function'`. A by-value check on the field alone misses it, leaking a live
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Zeroed {@link AggregateUsage}. A factory, not a shared const, because callers
|
|
3
3
|
* accumulate into the returned object.
|
|
4
4
|
*
|
|
5
|
-
* @
|
|
5
|
+
* @beta
|
|
6
6
|
*/
|
|
7
7
|
export function emptyUsage() {
|
|
8
8
|
return {
|
|
@@ -23,7 +23,7 @@ export function emptyUsage() {
|
|
|
23
23
|
* NOT the count of distinct tokens in the conversation, so it grows
|
|
24
24
|
* super-linearly with turn count and is expected to dwarf the context size.
|
|
25
25
|
*
|
|
26
|
-
* @
|
|
26
|
+
* @beta
|
|
27
27
|
*/
|
|
28
28
|
export function totalTokens(usage) {
|
|
29
29
|
return (usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens + usage.outputTokens);
|
|
@@ -35,7 +35,7 @@ export function totalTokens(usage) {
|
|
|
35
35
|
* proves. Pure rather than in-place because both operands are live state a caller
|
|
36
36
|
* must not mutate.
|
|
37
37
|
*
|
|
38
|
-
* @
|
|
38
|
+
* @beta
|
|
39
39
|
*/
|
|
40
40
|
export function addUsage(a, b) {
|
|
41
41
|
return {
|
|
@@ -70,7 +70,40 @@ export function addUsage(a, b) {
|
|
|
70
70
|
* Returns a zeroed total when no message carries usage — providers that report
|
|
71
71
|
* none (e.g. Chrome built-in) are skipped silently rather than guessed at.
|
|
72
72
|
*
|
|
73
|
-
* @
|
|
73
|
+
* @remarks
|
|
74
|
+
* This sums the WHOLE array, which is the right answer only when the array is the
|
|
75
|
+
* whole of the work being billed. A driver whose conversation **continues across
|
|
76
|
+
* rounds** opens its history with turns an earlier round already paid for, so
|
|
77
|
+
* summing it again bills them twice — and the session total then grows
|
|
78
|
+
* quadratically in the number of rounds while every individual figure in it stays
|
|
79
|
+
* correct. Nothing about the result looks wrong, which is why it is called out
|
|
80
|
+
* here rather than left to the caller to notice.
|
|
81
|
+
*
|
|
82
|
+
* Do NOT solve this with a "sum from this message onwards" marker. Compaction
|
|
83
|
+
* *replaces* the messages it summarises — and compaction is one of this function's
|
|
84
|
+
* own call sites — so the marker message can be absent from history by the time
|
|
85
|
+
* the sum runs, leaving the caller to either fail or silently sum everything,
|
|
86
|
+
* which is the double-count the marker was meant to prevent.
|
|
87
|
+
*
|
|
88
|
+
* Instead hold a **banked baseline**: spend that predates the current transcript,
|
|
89
|
+
* carried as a value, with `usage = banked + live` composed via {@link addUsage}.
|
|
90
|
+
* `resolveBankedUsage` in `utils/cost-session-history.ts` is the worked form, and
|
|
91
|
+
* the shape of its signature is the load-bearing part. It separates three cases:
|
|
92
|
+
*
|
|
93
|
+
* 1. no prior record — a genuine first round, so nothing is banked;
|
|
94
|
+
* 2. prior record, and the live transcript already accounts for it (a restored
|
|
95
|
+
* session) — carry the prior record's own banked figure forward;
|
|
96
|
+
* 3. prior record, and the transcript starts empty — bank the prior record's
|
|
97
|
+
* whole total, because nothing in the transcript can re-derive it.
|
|
98
|
+
*
|
|
99
|
+
* Case 1 and case 3 take the same arithmetic and mean opposite things, so the
|
|
100
|
+
* prior record must be a **required** input with no zero-defaulting path: a
|
|
101
|
+
* caller who carries a transcript and forgets its baseline reports the entire
|
|
102
|
+
* prior conversation as this round's spend. Distinguishing 2 from 3 is a separate
|
|
103
|
+
* decision, and getting it wrong is destructive in the other direction — it
|
|
104
|
+
* rewrites a total down to the current page-load and drops earlier spend.
|
|
105
|
+
*
|
|
106
|
+
* @beta
|
|
74
107
|
*/
|
|
75
108
|
export function sumUsage(messages) {
|
|
76
109
|
const total = emptyUsage();
|