@genesislcap/ai-assistant 15.15.2 → 15.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/ai-assistant.api.json +271 -2
  2. package/dist/ai-assistant.d.ts +90 -1
  3. package/dist/chat-driver.cjs +72 -0
  4. package/dist/chat-driver.cjs.map +2 -2
  5. package/dist/chat-driver.mjs +72 -0
  6. package/dist/chat-driver.mjs.map +2 -2
  7. package/dist/custom-elements.json +221 -97
  8. package/dist/dts/components/ai-driver/ai-driver.d.ts +11 -0
  9. package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/chat-driver.d.ts +31 -1
  11. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  12. package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.d.ts +10 -0
  13. package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.d.ts.map +1 -1
  14. package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts +2 -0
  15. package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts.map +1 -0
  16. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +3 -0
  17. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  18. package/dist/dts/index.d.ts +1 -1
  19. package/dist/dts/index.d.ts.map +1 -1
  20. package/dist/dts/main/file-attachments.test.d.ts +2 -0
  21. package/dist/dts/main/file-attachments.test.d.ts.map +1 -0
  22. package/dist/dts/main/interaction-cost.test.d.ts +2 -0
  23. package/dist/dts/main/interaction-cost.test.d.ts.map +1 -0
  24. package/dist/dts/main/main.d.ts +27 -0
  25. package/dist/dts/main/main.d.ts.map +1 -1
  26. package/dist/dts/main/main.template.d.ts.map +1 -1
  27. package/dist/dts/react.d.ts +2 -1
  28. package/dist/dts/state/debug-event-log.d.ts +1 -1
  29. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  30. package/dist/dts/types/ai-chat-widget.d.ts +8 -0
  31. package/dist/dts/types/ai-chat-widget.d.ts.map +1 -1
  32. package/dist/esm/components/chat-driver/chat-driver.js +75 -1
  33. package/dist/esm/components/chat-driver/chat-driver.test.js +90 -0
  34. package/dist/esm/components/chat-interaction-wrapper/chat-interaction-wrapper.js +22 -0
  35. package/dist/esm/components/orchestrating-driver/orchestrating-driver.cost.test.js +76 -0
  36. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +14 -0
  37. package/dist/esm/main/file-attachments.test.js +164 -0
  38. package/dist/esm/main/interaction-cost.test.js +107 -0
  39. package/dist/esm/main/main.js +193 -21
  40. package/dist/esm/main/main.template.js +1 -0
  41. package/dist/esm/state/debug-event-log.js +6 -0
  42. package/dist/react.cjs +6 -1
  43. package/dist/react.mjs +6 -1
  44. package/dist/tsconfig.tsbuildinfo +1 -1
  45. package/package.json +17 -17
  46. package/src/components/ai-driver/ai-driver.ts +11 -0
  47. package/src/components/chat-driver/chat-driver.test.ts +109 -0
  48. package/src/components/chat-driver/chat-driver.ts +78 -1
  49. package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts +29 -0
  50. package/src/components/orchestrating-driver/orchestrating-driver.cost.test.ts +107 -0
  51. package/src/components/orchestrating-driver/orchestrating-driver.ts +17 -0
  52. package/src/index.ts +4 -1
  53. package/src/main/file-attachments.test.ts +215 -0
  54. package/src/main/interaction-cost.test.ts +140 -0
  55. package/src/main/main.template.ts +1 -0
  56. package/src/main/main.ts +204 -22
  57. package/src/state/debug-event-log.ts +8 -0
  58. package/src/types/ai-chat-widget.ts +8 -0
@@ -0,0 +1,76 @@
1
+ import { __awaiter } from "tslib";
2
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
+ // Side-effect import — MUST precede `./orchestrating-driver` so the driver
4
+ // subclasses jsdom's EventTarget (CustomEvent dispatch then works in node).
5
+ import '../chat-driver/align-event-globals';
6
+ import { OrchestratingDriver } from './orchestrating-driver';
7
+ // GENC-1508 — `recordExternalCost` is OPTIONAL on `AiDriver`, so a missing delegation here
8
+ // is not a compile error: the host's `driver?.recordExternalCost?.(…)` simply evaluates to
9
+ // undefined and every post-resolve charge from every widget disappears — silently, and only
10
+ // in orchestrated setups. That is the same invisible-when-broken failure the method exists to
11
+ // fix, so the delegation gets a test rather than trust.
12
+ const makeRegistry = (provider) => ({
13
+ get: () => provider,
14
+ default: () => provider,
15
+ defaultName: () => 'test',
16
+ names: () => ['test'],
17
+ getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
18
+ listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
19
+ });
20
+ const noToolProvider = () => ({
21
+ chat: (_history, _userMessage, _options) => __awaiter(void 0, void 0, void 0, function* () { return ({ role: 'assistant', content: 'ok' }); }),
22
+ });
23
+ const agent = (overrides) => (Object.assign({ description: 'test agent' }, overrides));
24
+ const makeDriver = () => new OrchestratingDriver(makeRegistry(noToolProvider()), [agent({ name: 'Genesis Assistant' })], {
25
+ sessionKey: 'k',
26
+ });
27
+ /** The orchestrator does not expose `requestInteraction`, so the interaction is ARRANGED on the
28
+ * wrapped driver. Every assertion below goes through the orchestrator's own public surface. */
29
+ const wrapped = (driver) => driver.chatDriver;
30
+ const Suite = createLogicSuite('orchestrating-driver external cost');
31
+ Suite('post-resolve spend delegates to the wrapped driver', () => __awaiter(void 0, void 0, void 0, function* () {
32
+ const driver = makeDriver();
33
+ const inner = wrapped(driver);
34
+ const pending = inner.requestInteraction('planning-question', { question: 'Pick one' });
35
+ const id = driver.getRawHistory().at(-1).interaction.interactionId;
36
+ driver.resolveInteraction(id, { status: 'approved', costUsd: 0.5 });
37
+ yield pending;
38
+ assert.is(driver.recordExternalCost(id, 0.25), true);
39
+ const msg = driver.getRawHistory().find((m) => { var _a; return ((_a = m.interaction) === null || _a === void 0 ? void 0 : _a.interactionId) === id; });
40
+ assert.is(msg === null || msg === void 0 ? void 0 : msg.externalCostUsd, 0.75);
41
+ }));
42
+ Suite('an unknown interaction is reported as unrecorded, not thrown', () => __awaiter(void 0, void 0, void 0, function* () {
43
+ const driver = makeDriver();
44
+ assert.is(driver.recordExternalCost('never-existed', 0.25), false);
45
+ }));
46
+ Suite('the announcement reaches the HOST, which only ever holds this wrapper', () => __awaiter(void 0, void 0, void 0, function* () {
47
+ // The delegation test above passes with the forward missing: the money still lands on the
48
+ // message. What breaks is quieter — `createDriver` hands the host an OrchestratingDriver for any
49
+ // non-empty `agents`, so an event the inner driver raises and the constructor's forward list
50
+ // omits is one the host never sees. The totals then never refresh and the save is never
51
+ // scheduled, and because this flow's interaction is the last thing in the session, "stale until
52
+ // the next history change" means permanently. Asserted on the WRAPPER for that reason.
53
+ const driver = makeDriver();
54
+ const inner = wrapped(driver);
55
+ const pending = inner.requestInteraction('planning-question', { question: 'Pick one' });
56
+ const id = driver.getRawHistory().at(-1).interaction.interactionId;
57
+ driver.resolveInteraction(id, { status: 'approved' });
58
+ yield pending;
59
+ let seen = 0;
60
+ let detail;
61
+ driver.addEventListener('external-cost-recorded', (e) => {
62
+ seen += 1;
63
+ detail = e.detail;
64
+ });
65
+ // …and never on `history-updated`, which would rebuild the reporting widget.
66
+ let historyUpdated = 0;
67
+ driver.addEventListener('history-updated', () => {
68
+ historyUpdated += 1;
69
+ });
70
+ driver.recordExternalCost(id, 0.25);
71
+ assert.is(seen, 1);
72
+ assert.is(detail === null || detail === void 0 ? void 0 : detail.costUsd, 0.25);
73
+ assert.is(detail === null || detail === void 0 ? void 0 : detail.interactionId, id);
74
+ assert.is(historyUpdated, 0);
75
+ }));
76
+ Suite.run();
@@ -151,10 +151,24 @@ export class OrchestratingDriver extends EventTarget {
151
151
  this.chatDriver.addEventListener('provider-changed', (e) => {
152
152
  this.dispatchEvent(new CustomEvent('provider-changed', { detail: e.detail }));
153
153
  });
154
+ // Post-resolve external cost. The host only ever holds THIS wrapper (`createDriver` returns
155
+ // one for any non-empty `agents`), so an event the inner driver raises and this list omits is
156
+ // an event the host never sees: `recordExternalCost` still delegates and the money still lands
157
+ // on the message, but the totals never refresh and the save is never scheduled — and in the
158
+ // flow this exists for the interaction is the last thing in the session, so "stale until the
159
+ // next history change" means permanently. Reviewer catch on #2474.
160
+ this.chatDriver.addEventListener('external-cost-recorded', (e) => {
161
+ this.dispatchEvent(new CustomEvent('external-cost-recorded', { detail: e.detail }));
162
+ });
154
163
  }
155
164
  resolveInteraction(interactionId, result) {
156
165
  this.chatDriver.resolveInteraction(interactionId, result);
157
166
  }
167
+ /** Delegated for the same reason as `resolveInteraction`: the history that owns the
168
+ * interaction — and therefore the cost — lives on the wrapped driver, not here. */
169
+ recordExternalCost(interactionId, costUsd) {
170
+ return this.chatDriver.recordExternalCost(interactionId, costUsd);
171
+ }
158
172
  getInteractionContext(interactionId) {
159
173
  return this.chatDriver.getInteractionContext(interactionId);
160
174
  }
@@ -0,0 +1,164 @@
1
+ import { __awaiter } from "tslib";
2
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
+ import { FoundationAiAssistant } from './main';
4
+ // Hold a reference so the custom-element registration isn't tree-shaken.
5
+ FoundationAiAssistant;
6
+ // GENC-1508 — image files become IMAGE attachments, not text.
7
+ //
8
+ // `processFiles` read every accepted file with `readAsText`, so an accepted .png
9
+ // arrived as mojibake the model "read" as prose — an accepted image was worse than a
10
+ // rejected one. The layout-apply flow attaches the picked mockup's PNG beside its
11
+ // spec, which is what forced the branch: images now read as base64 and ship as
12
+ // `{kind:'image'}`, the shape the transports render as real image blocks.
13
+ //
14
+ // Unconnected elements (createElement upgrades but does not connect), same as
15
+ // blocked-state.test.ts — processFiles touches no session state.
16
+ const Suite = createLogicSuite('FoundationAiAssistant file attachments');
17
+ function element(acceptedFiles) {
18
+ const el = document.createElement('foundation-ai-assistant');
19
+ el.chatConfig = { ui: { acceptedFiles } };
20
+ return el;
21
+ }
22
+ /** A tiny valid PNG header is unnecessary — processFiles never parses pixels. */
23
+ const PNG_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
24
+ Suite('an accepted image file becomes a kind:image attachment with raw base64', () => __awaiter(void 0, void 0, void 0, function* () {
25
+ const el = element('.md,image/*');
26
+ const { attachments, errors } = yield el.processFiles([
27
+ new File([PNG_BYTES], 'layout-mockup.png', { type: 'image/png' }),
28
+ ]);
29
+ assert.equal(errors, []);
30
+ const [img] = attachments;
31
+ assert.is(img.kind, 'image');
32
+ assert.is(img.name, 'layout-mockup.png');
33
+ assert.is(img.mimeType, 'image/png');
34
+ // RAW base64 — a `data:` prefix left on would be sent to the vendor inside the payload.
35
+ assert.ok(img.data && !img.data.startsWith('data:'), 'data must be bare base64');
36
+ assert.is(atob(img.data).length, PNG_BYTES.length);
37
+ }));
38
+ Suite('a text file on the same submit stays a text attachment', () => __awaiter(void 0, void 0, void 0, function* () {
39
+ const el = element('.md,image/*');
40
+ const { attachments } = yield el.processFiles([
41
+ new File(['# spec'], 'layout-spec.md', { type: 'text/markdown' }),
42
+ new File([PNG_BYTES], 'layout-mockup.png', { type: 'image/png' }),
43
+ ]);
44
+ const [text, img] = attachments;
45
+ assert.is(text.kind, undefined);
46
+ assert.is(text.content, '# spec');
47
+ assert.is(img.kind, 'image');
48
+ }));
49
+ Suite('an image type the host did not accept is still refused', () => __awaiter(void 0, void 0, void 0, function* () {
50
+ const el = element('.md,.txt');
51
+ const { attachments, errors } = yield el.processFiles([
52
+ new File([PNG_BYTES], 'layout-mockup.png', { type: 'image/png' }),
53
+ ]);
54
+ assert.equal(attachments, []);
55
+ assert.is(errors.length, 1);
56
+ assert.match(errors[0], /not an accepted file type/);
57
+ }));
58
+ // A ~70KB image: the base64 loop's chunk size is 32768, so this crosses the boundary twice.
59
+ // The 8-byte fixture above executes the loop body exactly once, which would not notice an
60
+ // off-by-one in the subarray bounds.
61
+ Suite('a multi-chunk image round-trips byte-for-byte across the base64 loop', () => __awaiter(void 0, void 0, void 0, function* () {
62
+ const big = new Uint8Array(70000);
63
+ // A non-trivial pattern — an all-zero buffer survives most encoding mistakes.
64
+ for (let i = 0; i < big.length; i += 1)
65
+ big[i] = (i * 31 + (i >> 8)) & 0xff;
66
+ const el = element('image/*');
67
+ const { attachments, errors } = yield el.processFiles([
68
+ new File([big], 'wide.png', { type: 'image/png' }),
69
+ ]);
70
+ assert.equal(errors, []);
71
+ const [img] = attachments;
72
+ const back = atob(img.data);
73
+ assert.is(back.length, big.length);
74
+ let mismatch = -1;
75
+ for (let i = 0; i < big.length; i += 1) {
76
+ if (back.charCodeAt(i) !== big[i]) {
77
+ mismatch = i;
78
+ break;
79
+ }
80
+ }
81
+ assert.is(mismatch, -1, 'every byte must survive the chunked encode');
82
+ }));
83
+ Suite('an image over the size cap is refused by NAME and size, not sent to the vendor', () => __awaiter(void 0, void 0, void 0, function* () {
84
+ // Refused here rather than at the API: a vendor 400 lands after the user has waited out the
85
+ // turn, and the encoded blob is persisted meanwhile — one oversized image can blow the storage
86
+ // quota and silently stop the session autosaving.
87
+ const el = element('image/*');
88
+ const { attachments, errors } = yield el.processFiles([
89
+ new File([new Uint8Array(4000000)], 'huge.png', { type: 'image/png' }),
90
+ ]);
91
+ assert.equal(attachments, []);
92
+ assert.is(errors.length, 1);
93
+ assert.match(errors[0], /huge\.png/);
94
+ assert.match(errors[0], /limited to/);
95
+ }));
96
+ Suite('an image type the vendors do not accept falls back to TEXT, never a failed turn', () => __awaiter(void 0, void 0, void 0, function* () {
97
+ // `image/*` covers HEIC, BMP, TIFF, SVG — none of which the APIs take, and sending one fails
98
+ // the whole turn. SVG especially: it IS text, so the text branch gives the model something it
99
+ // can actually read.
100
+ const el = element('image/*');
101
+ const { attachments, errors } = yield el.processFiles([
102
+ new File(['<svg xmlns="http://www.w3.org/2000/svg"><rect /></svg>'], 'icon.svg', {
103
+ type: 'image/svg+xml',
104
+ }),
105
+ new File([new Uint8Array([0, 1, 2, 3])], 'photo.heic', { type: 'image/heic' }),
106
+ ]);
107
+ assert.equal(errors, []);
108
+ const [svg, heic] = attachments;
109
+ assert.is(svg.kind, undefined, 'SVG stays text — a model can read it');
110
+ assert.match(svg.content, /<rect/);
111
+ assert.is(heic.kind, undefined, 'an unsupported binary degrades rather than failing the turn');
112
+ }));
113
+ Suite('every supported spelling takes the image branch, including image/jpg', () => __awaiter(void 0, void 0, void 0, function* () {
114
+ const el = element('image/*');
115
+ const { attachments } = yield el.processFiles(['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'].map((type, i) => new File([PNG_BYTES], `f${i}`, { type })));
116
+ assert.equal(attachments.map((a) => a.kind), ['image', 'image', 'image', 'image', 'image']);
117
+ }));
118
+ // The TOTAL, not just each file. `loadSelectedFiles` appends across successive picks and the
119
+ // per-file check is blind to what is already attached, so ten files each just under the per-image
120
+ // ceiling used to be ten passes and one ~34MB request — assembled entirely out of files that each
121
+ // passed. Reviewer catch on #2474.
122
+ Suite('the file that crosses the total is refused; the ones that fit are kept', () => __awaiter(void 0, void 0, void 0, function* () {
123
+ const el = element('image/*');
124
+ const mb = (n) => new Uint8Array(n * 1000000);
125
+ const { attachments, errors } = yield el.processFiles([
126
+ new File([mb(3)], 'a.png', { type: 'image/png' }),
127
+ new File([mb(3)], 'b.png', { type: 'image/png' }),
128
+ new File([mb(3)], 'c.png', { type: 'image/png' }), // 9MB total — past the 8MB ceiling
129
+ ]);
130
+ assert.is(attachments.length, 2, 'the two that fit are accepted');
131
+ assert.is(errors.length, 1);
132
+ assert.match(errors[0], /c\.png/);
133
+ assert.match(errors[0], /attachment limit/);
134
+ }));
135
+ Suite('the budget counts what is ALREADY attached, not just this pick', () => __awaiter(void 0, void 0, void 0, function* () {
136
+ // The picker's second pick has to see the first one's bytes, or "under the limit" is measured
137
+ // against zero every time and the limit means nothing.
138
+ const el = element('image/*');
139
+ const fresh = yield el.processFiles([
140
+ new File([new Uint8Array(3000000)], 'a.png', { type: 'image/png' }),
141
+ ]);
142
+ assert.is(fresh.attachments.length, 1, 'fits on its own');
143
+ const withHistory = yield el.processFiles([new File([new Uint8Array(3000000)], 'b.png', { type: 'image/png' })], 6000000);
144
+ assert.is(withHistory.attachments.length, 0, 'the same file is refused once 6MB is already committed');
145
+ assert.match(withHistory.errors[0], /6\.0MB already attached/);
146
+ }));
147
+ Suite('a refusal skips only the offending file — a later one that still fits is kept', () => __awaiter(void 0, void 0, void 0, function* () {
148
+ // Budgeted synchronously off `file.size`, in the user's own pick order, before anything is read
149
+ // (deciding inside the parallel reads would hand the last slot to whichever finished first).
150
+ // A file that does not fit is refused and the budget carries on, so a smaller one behind it is
151
+ // not punished for its neighbour.
152
+ const el = element('image/*');
153
+ const mb = (n) => new Uint8Array(n * 1000000);
154
+ const { attachments, errors } = yield el.processFiles([
155
+ new File([mb(3)], 'a.png', { type: 'image/png' }), // running 3
156
+ new File([mb(3)], 'b.png', { type: 'image/png' }), // running 6
157
+ new File([mb(3)], 'big.png', { type: 'image/png' }), // 9 > 8 — refused
158
+ new File([mb(1)], 'small.png', { type: 'image/png' }), // 7 — still fits
159
+ ]);
160
+ assert.equal(attachments.map((a) => a.name), ['a.png', 'b.png', 'small.png']);
161
+ assert.is(errors.length, 1);
162
+ assert.match(errors[0], /big\.png/);
163
+ }));
164
+ Suite.run();
@@ -0,0 +1,107 @@
1
+ import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
2
+ import { getSessionStore } from '../state/session-store';
3
+ import { FoundationAiAssistant } from './main';
4
+ // Hold a reference so the custom-element registration isn't tree-shaken.
5
+ FoundationAiAssistant;
6
+ // GENC-1508 — the host hop of the post-resolve cost channel.
7
+ //
8
+ // The driver method and the orchestrator delegation both have tests; this pins the hop
9
+ // between them, which is the one most likely to break silently: a rename on either side of
10
+ // `handleInteractionCost` is a no-op that every other test still passes, and the "no spinner"
11
+ // guarantee is a design promise stated only in a comment.
12
+ //
13
+ // Unconnected element (createElement upgrades but does not connect), same as
14
+ // blocked-state.test.ts — this handler touches no session state.
15
+ const Suite = createLogicSuite('FoundationAiAssistant interaction cost');
16
+ function host() {
17
+ const el = document.createElement('foundation-ai-assistant');
18
+ const calls = [];
19
+ const state = { spinners: 0 };
20
+ el.driver = {
21
+ recordExternalCost: (id, cost) => {
22
+ calls.push([id, cost]);
23
+ return true;
24
+ },
25
+ };
26
+ el.startLoadingTimer = () => {
27
+ state.spinners += 1;
28
+ };
29
+ return {
30
+ el,
31
+ calls,
32
+ get spinners() {
33
+ return state.spinners;
34
+ },
35
+ };
36
+ }
37
+ const costEvent = (detail) => new CustomEvent('interaction-cost', { detail });
38
+ Suite('a cost report reaches the driver', () => {
39
+ const h = host();
40
+ h.el.handleInteractionCost(costEvent({ interactionId: 'abc', costUsd: 0.42 }));
41
+ assert.equal(h.calls, [['abc', 0.42]]);
42
+ });
43
+ Suite('reporting cost NEVER starts the loading spinner', () => {
44
+ // The whole point of the channel: this is bookkeeping about a turn that already finished, so
45
+ // it must be invisible. A spinner would tell the user the assistant is working when nothing
46
+ // is pending — the one thing `handleInteractionCompleted` does that this must not copy.
47
+ const h = host();
48
+ h.el.handleInteractionCost(costEvent({ interactionId: 'abc', costUsd: 0.42 }));
49
+ assert.is(h.spinners, 0);
50
+ });
51
+ Suite('an event with no interactionId is ignored', () => {
52
+ const h = host();
53
+ h.el.handleInteractionCost(costEvent({ costUsd: 0.42 }));
54
+ h.el.handleInteractionCost(costEvent(undefined));
55
+ assert.equal(h.calls, []);
56
+ });
57
+ Suite('a driver that does not implement the optional method is not called into', () => {
58
+ // `recordExternalCost` is optional on `AiDriver`, so a driver predating this — or one that
59
+ // simply does not track spend — must be ignored rather than throw.
60
+ const h = host();
61
+ h.el.driver = {};
62
+ assert.not.throws(() => h.el.handleInteractionCost(costEvent({ interactionId: 'a', costUsd: 1 })));
63
+ h.el.driver = undefined;
64
+ assert.not.throws(() => h.el.handleInteractionCost(costEvent({ interactionId: 'a', costUsd: 1 })));
65
+ });
66
+ Suite('a driver without the method leaves a TRACE, not a silent no-op', () => {
67
+ // Reviewer catch on #2474: every other hop traces its drops. A driver predating
68
+ // `recordExternalCost` made this one a silent no-op — money gone, nothing recorded — which is
69
+ // the invisible gap the whole channel exists to close, reintroduced at the last hop.
70
+ const h = host();
71
+ const meta = [];
72
+ h.el.logMeta = (t, d) => {
73
+ meta.push([t, d]);
74
+ };
75
+ h.el.driver = {};
76
+ h.el.handleInteractionCost(costEvent({ interactionId: 'abc', costUsd: 0.42 }));
77
+ assert.is(meta.length, 1);
78
+ assert.is(meta[0][0], 'interaction.external-cost-dropped');
79
+ assert.is(meta[0][1].costUsd, 0.42);
80
+ assert.is(meta[0][1].interactionId, 'abc');
81
+ });
82
+ // The OUTBOUND half: the driver announced, and the host has to turn that into a moved number.
83
+ // Tested because it is the same shape of hop as the one that already went wrong — the wrapper
84
+ // forward that was missing — and because it deliberately reads the DRIVER's history rather than
85
+ // `this.messages`, which is the part a reader would most likely "simplify" back.
86
+ let storeSeq = 0;
87
+ /** An element wired to its own real session store, so `sessionUsage` round-trips. */
88
+ function costHost() {
89
+ const el = document.createElement('foundation-ai-assistant');
90
+ storeSeq += 1;
91
+ el._sessionRef = getSessionStore(`cost-test-${(storeSeq += 1)}`, false);
92
+ return el;
93
+ }
94
+ Suite('the recorded spend reaches the session total from the DRIVER history', () => {
95
+ const el = costHost();
96
+ const sync = el.syncUsageTotals.bind(el);
97
+ sync([{ role: 'assistant', content: 'a', cost: 0.1, model: 'claude-sonnet-5' }]);
98
+ assert.is(el.sessionCostUsd, 0.1);
99
+ // The post-resolve delta lands on the interaction message as `externalCostUsd`; the total has
100
+ // to fold it in, or every hop before this one was wasted.
101
+ sync([
102
+ { role: 'assistant', content: 'a', cost: 0.1, model: 'claude-sonnet-5' },
103
+ { role: 'assistant', content: 'b', externalCostUsd: 0.25 },
104
+ ]);
105
+ assert.is(el.sessionCostUsd, 0.35);
106
+ });
107
+ Suite.run();
@@ -107,6 +107,62 @@ const SESSION_MENU_CLOSE_MS = 200;
107
107
  * assistant is running — the Usage tab renders the in-memory list — so the delay is invisible.
108
108
  */
109
109
  const LEDGER_FLUSH_DEBOUNCE_MS = 2000;
110
+ /**
111
+ * The image types BOTH vendors accept, and therefore the only ones sent as image blocks.
112
+ *
113
+ * A host's `acceptedFiles` may well say `image/*`, which covers HEIC, BMP, TIFF, AVIF and SVG —
114
+ * none of which the APIs take. Sending one fails the whole turn the user is waiting on, so
115
+ * anything outside this set falls through to the text branch instead: harmless for a binary, and
116
+ * genuinely useful for SVG, which is text a model can read.
117
+ *
118
+ * `image/jpg` is absent on purpose — `normalizeImageMime` rewrites it to `image/jpeg` at the
119
+ * transport, but a file arriving with that spelling is still a real JPEG, so it is accepted here.
120
+ */
121
+ const VENDOR_IMAGE_MIME_TYPES = new Set([
122
+ 'image/jpeg',
123
+ 'image/jpg',
124
+ 'image/png',
125
+ 'image/gif',
126
+ 'image/webp',
127
+ ]);
128
+ /**
129
+ * Ceiling on ONE image attachment, under Anthropic's ~5MB-per-image limit with room for the ~33%
130
+ * base64 overhead. Refused here, with the size named, rather than at the vendor: a 400 arrives
131
+ * only after the user has waited out the turn.
132
+ *
133
+ * A VENDOR guard, and only that. An earlier version of this comment also claimed it protected the
134
+ * session's storage quota — it does not, and the arithmetic says so: one file at this ceiling is
135
+ * ~4.7MB of base64, which alone can exceed a 5MB `localStorage` budget before the transcript is
136
+ * counted. Attachments riding into persisted storage is a real and separate problem; it is not
137
+ * this constant's to solve, and saying otherwise hid it.
138
+ */
139
+ const MAX_IMAGE_ATTACHMENT_BYTES = 3500000;
140
+ /**
141
+ * Ceiling on ALL attachments carried by one message, across every type and every successive pick.
142
+ *
143
+ * The per-file check above is blind to the total: the picker APPENDS (`loadSelectedFiles`), so ten
144
+ * files each just under the per-image limit are ten passes and one ~34MB request. Sized against
145
+ * the vendors' request ceilings — comfortably inside the smaller of them with room for the
146
+ * transcript itself — so a turn that would be rejected wholesale is refused here, where the user
147
+ * can still act on it, naming the file that crossed the line.
148
+ *
149
+ * Like its sibling, this does NOT make attachments safe to persist — 8MB of attachments is still
150
+ * ~10.7MB of base64. What a persisted session should keep of its images is GENC-1531; note the
151
+ * transports also REPLAY history attachments on every later call, so it is a recurring cost as
152
+ * well as a storage one.
153
+ */
154
+ const MAX_ATTACHMENTS_TOTAL_BYTES = 8000000;
155
+ /** Base64 carries 3 bytes in every 4 characters, so decoded size is 3/4 of the encoded length. */
156
+ const BASE64_DECODED_RATIO = 0.75;
157
+ /** A rough byte size for an already-accepted attachment, for budgeting against the total cap. */
158
+ function attachmentBytes(a) {
159
+ var _a, _b, _c, _d;
160
+ return a.kind === 'image'
161
+ ? Math.ceil(((_b = (_a = a.data) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) * BASE64_DECODED_RATIO)
162
+ : ((_d = (_c = a.content) === null || _c === void 0 ? void 0 : _c.length) !== null && _d !== void 0 ? _d : 0);
163
+ }
164
+ /** Bytes per megabyte, for the size the refusal message quotes back to the user. */
165
+ const BYTES_PER_MB = 1000000;
110
166
  /**
111
167
  * Reporter id for this element's OWN session persister in `brokenSources` (GENC-1511).
112
168
  *
@@ -2432,6 +2488,23 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2432
2488
  (_b = this.persister()) === null || _b === void 0 ? void 0 : _b.scheduleDiagnostics();
2433
2489
  };
2434
2490
  driver.addEventListener('history-updated', onHistoryUpdated);
2491
+ // Post-resolve external cost. Deliberately does NOT reassign `messages`: that would rebuild
2492
+ // the trailing interaction row (a `recycle: false` repeat) and blank the widget that just
2493
+ // reported — see `recordExternalCost`. The totals come off the DRIVER's history because the
2494
+ // host's copy holds the pre-update message objects, and the persister already reads the
2495
+ // driver first, so the save picks the field up with no help from here.
2496
+ const onExternalCostRecorded = () => {
2497
+ var _a, _b;
2498
+ if (driver.getRawHistory)
2499
+ this.syncUsageTotals(driver.getRawHistory());
2500
+ (_a = this.persister()) === null || _a === void 0 ? void 0 : _a.scheduleSave();
2501
+ // Diagnostics too, for the same reason `onHistoryUpdated` schedules both: the forward-append
2502
+ // is what carries `interaction.external-cost` (and its `-dropped` sibling) into the persisted
2503
+ // stream. Without it the trace this change adds sits in memory waiting for activity that,
2504
+ // in this flow, never comes — the interaction is the last thing in the session.
2505
+ (_b = this.persister()) === null || _b === void 0 ? void 0 : _b.scheduleDiagnostics();
2506
+ };
2507
+ driver.addEventListener('external-cost-recorded', onExternalCostRecorded);
2435
2508
  // Re-seed the rendered list from the driver's current history. `wireDriver`
2436
2509
  // runs on (re)connect, popin, and when adopting a sibling's driver
2437
2510
  // (GENC-1388); in those cases `messages` would otherwise stay stale until the
@@ -2510,6 +2583,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2510
2583
  driver.addEventListener('provider-changed', onProviderChanged);
2511
2584
  const cleanups = [
2512
2585
  () => driver.removeEventListener('history-updated', onHistoryUpdated),
2586
+ () => driver.removeEventListener('external-cost-recorded', onExternalCostRecorded),
2513
2587
  () => driver.removeEventListener('sub-agent-history-updated', onSubAgentHistoryUpdated),
2514
2588
  () => driver.removeEventListener('sub-agent-start', onSubAgentStart),
2515
2589
  () => driver.removeEventListener('sub-agent-stop', onSubAgentStop),
@@ -2990,6 +3064,36 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2990
3064
  var _a;
2991
3065
  this.showingSplash = !!((_a = this.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.showSplash) && this.messages.length === 0;
2992
3066
  }
3067
+ /**
3068
+ * Recompute the session's cost and token buckets from a transcript.
3069
+ *
3070
+ * Takes the list rather than reading `this.messages`, because it has two callers with
3071
+ * different sources: the `messages` setter passes the rendered list, and post-resolve external
3072
+ * cost passes the DRIVER's history — spend recorded there must reach the total WITHOUT
3073
+ * reassigning `messages`, which would re-render the live interaction row. See
3074
+ * `onExternalCostRecorded`.
3075
+ *
3076
+ * Recomputing (rather than incrementing on append) keeps the totals correct under any mutation
3077
+ * of the message list — clear-chat, re-render, restore. `sumUsage` recurses into
3078
+ * `toolCall.subAgentTrace`, so work a sub-agent did (possibly on another provider at other
3079
+ * rates) counts, and it reads the usage a compaction banked onto its summary, so shrinking
3080
+ * history no longer shrinks the totals.
3081
+ */
3082
+ syncUsageTotals(messages) {
3083
+ const running = sumUsage(messages);
3084
+ const current = this.sessionUsage;
3085
+ if (running.costUsd !== current.costUsd ||
3086
+ running.uncachedInputTokens !== current.uncachedInputTokens ||
3087
+ running.cacheReadTokens !== current.cacheReadTokens ||
3088
+ running.cacheWriteTokens !== current.cacheWriteTokens ||
3089
+ running.outputTokens !== current.outputTokens) {
3090
+ this.sessionUsage = running;
3091
+ // Keep this project's history row current as spend accrues, which is what lets
3092
+ // the Usage tab total be the sum of the rows alone. Idempotent (an upsert keyed
3093
+ // by the session key), so calling it per change is safe.
3094
+ this.finalizeCostSession();
3095
+ }
3096
+ }
2993
3097
  /**
2994
3098
  * Runs side effects that were previously in `messagesChanged()`.
2995
3099
  * Called from the `messages` setter after dispatching to the store.
@@ -3028,19 +3132,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
3028
3132
  // recurses into `toolCall.subAgentTrace`, so work a sub-agent did (possibly on
3029
3133
  // another provider at other rates) counts, and it reads the usage a compaction
3030
3134
  // banked onto its summary, so shrinking history no longer shrinks the totals.
3031
- const running = sumUsage(this.messages);
3032
- const current = this.sessionUsage;
3033
- if (running.costUsd !== current.costUsd ||
3034
- running.uncachedInputTokens !== current.uncachedInputTokens ||
3035
- running.cacheReadTokens !== current.cacheReadTokens ||
3036
- running.cacheWriteTokens !== current.cacheWriteTokens ||
3037
- running.outputTokens !== current.outputTokens) {
3038
- this.sessionUsage = running;
3039
- // Keep this project's history row current as spend accrues, which is what lets
3040
- // the Usage tab total be the sum of the rows alone. Idempotent (an upsert keyed
3041
- // by the session key), so calling it per change is safe.
3042
- this.finalizeCostSession();
3043
- }
3135
+ this.syncUsageTotals(this.messages);
3044
3136
  // Record a context.updated meta event when the token count changes (≈once
3045
3137
  // per LLM call, as a new usage-bearing message arrives), plus a one-shot
3046
3138
  // threshold-crossed event the first time usage passes 80%.
@@ -4271,21 +4363,75 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
4271
4363
  a === file.type ||
4272
4364
  (a.endsWith('/*') && file.type.startsWith(a.replace('/*', '/'))));
4273
4365
  }
4366
+ // Blob's own promise APIs, not FileReader: UTF-8 only (a `charset` parameter on the blob's
4367
+ // type is ignored, where FileReader would honour it), and they exist in the node test runtime
4368
+ // too — FileReader does not, so neither reader was testable before.
4274
4369
  readFileAsText(file) {
4275
- return new Promise((resolve, reject) => {
4276
- const reader = new FileReader();
4277
- reader.onload = () => resolve(reader.result);
4278
- reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`));
4279
- reader.readAsText(file);
4280
- });
4370
+ return file.text();
4281
4371
  }
4282
- processFiles(files) {
4372
+ /** The base64 payload of a file, without any `data:` prefix — the raw form
4373
+ * `ChatImageAttachment.data` specifies (transports add their own framing). */
4374
+ readFileAsBase64(file) {
4283
4375
  return __awaiter(this, void 0, void 0, function* () {
4376
+ const bytes = new Uint8Array(yield file.arrayBuffer());
4377
+ let binary = '';
4378
+ const CHUNK = 0x8000;
4379
+ for (let i = 0; i < bytes.length; i += CHUNK) {
4380
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
4381
+ }
4382
+ return btoa(binary);
4383
+ });
4384
+ }
4385
+ processFiles(files_1) {
4386
+ return __awaiter(this, arguments, void 0, function* (files, existingBytes = 0) {
4387
+ // Budget the batch BEFORE reading any of it. `file.size` needs no I/O, so the verdict is
4388
+ // deterministic and in the user's own pick order; deciding inside the parallel map below would
4389
+ // hand the last slot to whichever read happened to finish first. `existingBytes` is what the
4390
+ // caller has already committed to — the picker appends to a running list, so its files have to
4391
+ // count against what is already attached rather than starting from zero each time.
4392
+ let running = existingBytes;
4393
+ const overBudget = new Map();
4394
+ for (const file of files) {
4395
+ if (running + file.size > MAX_ATTACHMENTS_TOTAL_BYTES) {
4396
+ overBudget.set(file, `"${file.name}" would take this message past the ` +
4397
+ `${MAX_ATTACHMENTS_TOTAL_BYTES / BYTES_PER_MB}MB attachment limit ` +
4398
+ `(${(running / BYTES_PER_MB).toFixed(1)}MB already attached).`);
4399
+ continue;
4400
+ }
4401
+ running += file.size;
4402
+ }
4284
4403
  const results = yield Promise.all(files.map((file) => __awaiter(this, void 0, void 0, function* () {
4285
4404
  if (!this.isAcceptedFile(file)) {
4286
4405
  return { ok: false, message: `"${file.name}" is not an accepted file type.` };
4287
4406
  }
4407
+ const tooMuch = overBudget.get(file);
4408
+ if (tooMuch)
4409
+ return { ok: false, message: tooMuch };
4288
4410
  try {
4411
+ // An image file becomes a real image attachment — the transports render those as
4412
+ // image blocks the model SEES. Reading it as text (the only path that existed)
4413
+ // produced mojibake the model "read" as prose: an accepted .png was worse than a
4414
+ // rejected one. Text stays the default for everything else.
4415
+ //
4416
+ // Only the types the vendors actually accept take this branch. `image/*` covers far
4417
+ // more than that, and sending one an API rejects fails the whole turn the user is
4418
+ // waiting on — where the text branch merely degrades. It also keeps SVG working:
4419
+ // SVG *is* text, and reading it as such gives the model something it can genuinely
4420
+ // read, which a base64 image block of it would not.
4421
+ if (VENDOR_IMAGE_MIME_TYPES.has(file.type.toLowerCase())) {
4422
+ if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) {
4423
+ return {
4424
+ ok: false,
4425
+ message: `"${file.name}" is ${Math.ceil(file.size / BYTES_PER_MB)}MB; images are limited to ` +
4426
+ `${MAX_IMAGE_ATTACHMENT_BYTES / BYTES_PER_MB}MB.`,
4427
+ };
4428
+ }
4429
+ const data = yield this.readFileAsBase64(file);
4430
+ return {
4431
+ ok: true,
4432
+ attachment: { kind: 'image', name: file.name, mimeType: file.type, data },
4433
+ };
4434
+ }
4289
4435
  const content = yield this.readFileAsText(file);
4290
4436
  return {
4291
4437
  ok: true,
@@ -4317,7 +4463,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
4317
4463
  const input = e.target;
4318
4464
  const files = Array.from((_a = input.files) !== null && _a !== void 0 ? _a : []);
4319
4465
  input.value = '';
4320
- const { attachments, errors } = yield this.processFiles(files);
4466
+ const { attachments, errors } = yield this.processFiles(files, this.attachments.reduce((n, a) => n + attachmentBytes(a), 0));
4321
4467
  if (attachments.length) {
4322
4468
  this.attachments = [...this.attachments, ...attachments];
4323
4469
  this.logMeta('attachment.added', {
@@ -4575,6 +4721,32 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
4575
4721
  (_a = this.driver) === null || _a === void 0 ? void 0 : _a.resolveInteraction(interactionId, result);
4576
4722
  }
4577
4723
  }
4724
+ /**
4725
+ * A widget reporting spend it incurred AFTER resolving.
4726
+ *
4727
+ * Note what this deliberately does NOT do: no `startLoadingTimer()`. This is bookkeeping about a
4728
+ * turn that already finished, so it must be invisible — showing a spinner would tell the user the
4729
+ * assistant is working when nothing is pending.
4730
+ */
4731
+ handleInteractionCost(e) {
4732
+ var _a;
4733
+ const detail = e.detail;
4734
+ if (!(detail === null || detail === void 0 ? void 0 : detail.interactionId))
4735
+ return;
4736
+ // Optional on `AiDriver`, so call it optionally — a driver that predates this, or one that
4737
+ // simply doesn't track spend, should ignore the report rather than throw on it. TRACED,
4738
+ // because the spend is real and already billed: an untraced no-op here is the same invisible
4739
+ // gap this whole channel exists to close, reintroduced at the last hop. Reviewer catch.
4740
+ if (!((_a = this.driver) === null || _a === void 0 ? void 0 : _a.recordExternalCost)) {
4741
+ this.logMeta('interaction.external-cost-dropped', {
4742
+ interactionId: detail.interactionId,
4743
+ costUsd: detail.costUsd,
4744
+ reason: 'driver does not implement recordExternalCost',
4745
+ });
4746
+ return;
4747
+ }
4748
+ this.driver.recordExternalCost(detail.interactionId, detail.costUsd);
4749
+ }
4578
4750
  /**
4579
4751
  * The live {@link InteractionContext} for a pending interaction (GENC-1390).
4580
4752
  * `AiChatInteractionWrapper` pierces this across the shadow boundary to build the