@genesislcap/ai-assistant 15.6.2 → 15.7.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 +391 -5
- package/dist/ai-assistant.d.ts +613 -6
- package/dist/chat-driver.cjs +285 -26
- package/dist/chat-driver.cjs.map +3 -3
- package/dist/chat-driver.mjs +285 -26
- package/dist/chat-driver.mjs.map +3 -3
- package/dist/custom-elements.json +254 -10
- package/dist/dts/channel/ai-activity-channel.d.ts +51 -1
- package/dist/dts/channel/ai-activity-channel.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +99 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +2 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +1 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +14 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/main/blocked-state.test.d.ts +2 -0
- package/dist/dts/main/blocked-state.test.d.ts.map +1 -0
- package/dist/dts/main/main.d.ts +394 -6
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.styles.d.ts.map +1 -1
- package/dist/dts/main/main.styles.test.d.ts +2 -0
- package/dist/dts/main/main.styles.test.d.ts.map +1 -0
- package/dist/dts/main/main.template.d.ts +53 -0
- package/dist/dts/main/main.template.d.ts.map +1 -1
- package/dist/dts/state/ai-assistant-slice.d.ts +162 -6
- package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
- package/dist/dts/state/debug-event-log.d.ts +6 -1
- package/dist/dts/state/debug-event-log.d.ts.map +1 -1
- package/dist/dts/state/session-store.d.ts +11 -0
- package/dist/dts/state/session-store.d.ts.map +1 -1
- package/dist/esm/components/chat-driver/chat-driver.js +263 -21
- package/dist/esm/components/chat-driver/chat-driver.test.js +464 -1
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +312 -0
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +89 -4
- package/dist/esm/main/blocked-state.test.js +969 -0
- package/dist/esm/main/main.js +704 -16
- package/dist/esm/main/main.styles.js +47 -0
- package/dist/esm/main/main.styles.test.js +86 -0
- package/dist/esm/main/main.template.js +121 -4
- package/dist/esm/state/ai-assistant-slice.js +145 -7
- package/dist/esm/state/ai-assistant-slice.test.js +138 -1
- package/dist/esm/state/debug-event-log.js +7 -2
- package/dist/esm/state/debug-event-log.test.js +49 -1
- package/dist/esm/state/persistence/session-snapshot.test.js +18 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/docs/migration-GENC-1464.md +562 -0
- package/docs/sub_agent.md +20 -3
- package/package.json +17 -17
- package/src/channel/ai-activity-channel.ts +56 -2
- package/src/components/chat-driver/chat-driver.test.ts +549 -0
- package/src/components/chat-driver/chat-driver.ts +324 -14
- package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +438 -0
- package/src/components/orchestrating-driver/orchestrating-driver.ts +101 -6
- package/src/main/blocked-state.test.ts +1316 -0
- package/src/main/main.styles.test.ts +103 -0
- package/src/main/main.styles.ts +47 -0
- package/src/main/main.template.ts +131 -4
- package/src/main/main.ts +704 -10
- package/src/state/ai-assistant-slice.test.ts +215 -0
- package/src/state/ai-assistant-slice.ts +218 -8
- package/src/state/debug-event-log.test.ts +63 -0
- package/src/state/debug-event-log.ts +7 -2
- package/src/state/persistence/session-snapshot.test.ts +22 -0
|
@@ -435,6 +435,7 @@ const baseStyles = css `
|
|
|
435
435
|
.attachment-chips,
|
|
436
436
|
.agent-picker-panel,
|
|
437
437
|
.session-menu-panel,
|
|
438
|
+
.blocked-banner,
|
|
438
439
|
chat-suggestions {
|
|
439
440
|
box-sizing: border-box;
|
|
440
441
|
width: 100%;
|
|
@@ -458,6 +459,13 @@ const baseStyles = css `
|
|
|
458
459
|
border-radius: 12px;
|
|
459
460
|
}
|
|
460
461
|
|
|
462
|
+
/* The banner joins the same floating-card family, but keeps its own
|
|
463
|
+
warning-toned border — overriding it with the neutral stroke would erase
|
|
464
|
+
the one colour signal it carries. Only the radius needs to match. */
|
|
465
|
+
.blocked-banner.is-visible {
|
|
466
|
+
border-radius: 12px;
|
|
467
|
+
}
|
|
468
|
+
|
|
461
469
|
/* chat-suggestions paints its background on an inner element, so clip it to the
|
|
462
470
|
rounded corners — otherwise that square inner background bleeds past the radius
|
|
463
471
|
and the corners read as square with the border hidden behind them. */
|
|
@@ -611,6 +619,45 @@ const baseStyles = css `
|
|
|
611
619
|
font-size: 0.9em;
|
|
612
620
|
}
|
|
613
621
|
|
|
622
|
+
/* Backend-blocked banner (GENC-1464) — sits between the transcript and the
|
|
623
|
+
disabled composer. Flat and quiet rather than an error toast: the condition
|
|
624
|
+
is standing, not a momentary failure, so it should read as part of the
|
|
625
|
+
chrome the user is now working within. */
|
|
626
|
+
|
|
627
|
+
/* The container is ALWAYS in the DOM so the live region is registered before the
|
|
628
|
+
text arrives (see main.template.ts) — so the bare selector carries only layout,
|
|
629
|
+
and an unblocked banner is an empty, zero-height, invisible box. Everything
|
|
630
|
+
that would make it visible hangs off .is-visible. */
|
|
631
|
+
.blocked-banner {
|
|
632
|
+
display: flex;
|
|
633
|
+
align-items: center;
|
|
634
|
+
gap: calc(var(--design-unit) * 2px);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
.blocked-banner.is-visible {
|
|
638
|
+
/* Block axis only. The inline-axis margin is owned by the composer width group
|
|
639
|
+
above (margin-inline: auto), which centres the banner on the same column as
|
|
640
|
+
the input row — an inline margin here would fight it. */
|
|
641
|
+
margin-block: calc(var(--design-unit) * 1px);
|
|
642
|
+
padding: calc(var(--design-unit) * 2px) calc(var(--design-unit) * 3px);
|
|
643
|
+
border: calc(var(--stroke-width) * 1px) solid var(--warning-color);
|
|
644
|
+
border-radius: calc(var(--control-corner-radius) * 1px);
|
|
645
|
+
background: var(--neutral-layer-2);
|
|
646
|
+
color: var(--neutral-foreground-rest);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/* Partial exhaustion: at least one vendor is walled but the composer is still
|
|
650
|
+
live, so the banner is advice rather than a wall. Softened to match — same
|
|
651
|
+
layout, same part, lower alarm. */
|
|
652
|
+
.blocked-banner.is-partial {
|
|
653
|
+
border-color: var(--neutral-stroke-rest);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
.blocked-banner-text {
|
|
657
|
+
font-size: 0.9em;
|
|
658
|
+
line-height: 1.4;
|
|
659
|
+
}
|
|
660
|
+
|
|
614
661
|
.chat-input {
|
|
615
662
|
flex: 1;
|
|
616
663
|
resize: none;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
var _a, _b, _c;
|
|
2
|
+
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
3
|
+
import { styles } from './main.styles';
|
|
4
|
+
// CSS is not otherwise unit-tested in this package, so this covers the two
|
|
5
|
+
// properties of the BLOCKED BANNER that are cheap to assert from the generated
|
|
6
|
+
// text and that a reviewer had to catch by eye:
|
|
7
|
+
//
|
|
8
|
+
// 1. Design-system tokens are consumed UNFALLBACKED, as every other rule in the
|
|
9
|
+
// file does. A `var(--token, fallback)` here is dead code at best — both
|
|
10
|
+
// tokens below ship with defaults — and actively wrong at worst: the blocked
|
|
11
|
+
// banner's old `var(--neutral-layer-2, transparent)` would, if it ever fired,
|
|
12
|
+
// have produced a banner with no surface at all.
|
|
13
|
+
// 2. The blocked banner's visual treatment hangs off `.is-visible`, not the bare
|
|
14
|
+
// class, because the container stays mounted while unblocked so that its
|
|
15
|
+
// `role="status"` region is registered before the text arrives (see
|
|
16
|
+
// main.template.ts). Collapsing the empty container would undo that.
|
|
17
|
+
//
|
|
18
|
+
// Scope: the banner's own rules, and nothing else. Both negative assertions were
|
|
19
|
+
// written for one component, but an earlier version grepped the WHOLE stylesheet
|
|
20
|
+
// — quietly turning a banner test into a global style policy nobody agreed to, so
|
|
21
|
+
// any future rule anywhere in an 800-line sheet that legitimately wanted a
|
|
22
|
+
// fallback on either token would have failed a test named after the banner.
|
|
23
|
+
const suite = createLogicSuite('main.styles blocked-banner rules');
|
|
24
|
+
/**
|
|
25
|
+
* The generated stylesheet text. `css` composes nested `ElementStyles` (this file
|
|
26
|
+
* builds the sheet from several partials), so flatten recursively rather than
|
|
27
|
+
* only reading the top level — a shallow read silently returns almost nothing,
|
|
28
|
+
* which would make every `assert.not.ok` below pass vacuously. The first test
|
|
29
|
+
* guards exactly that.
|
|
30
|
+
*/
|
|
31
|
+
const flatten = (value) => {
|
|
32
|
+
if (typeof value === 'string')
|
|
33
|
+
return value;
|
|
34
|
+
if (Array.isArray(value))
|
|
35
|
+
return value.map(flatten).join('\n');
|
|
36
|
+
const nested = value === null || value === void 0 ? void 0 : value.styles;
|
|
37
|
+
return nested ? flatten(nested) : '';
|
|
38
|
+
};
|
|
39
|
+
const cssText = flatten(styles);
|
|
40
|
+
/**
|
|
41
|
+
* Every declaration block whose selector mentions `.blocked-banner`, joined.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately crude — one regex over the generated text, no CSS parser — but
|
|
44
|
+
* enough to keep these assertions about the banner rather than about the whole
|
|
45
|
+
* sheet, which was the only thing wrong with them. It matches
|
|
46
|
+
* `<selector> { <declarations> }` for any selector containing `.blocked-banner`,
|
|
47
|
+
* so it picks up the bare class, the `.is-visible` / `.is-partial` modifiers, the
|
|
48
|
+
* shared composer-width group and the copy inside the container query alike.
|
|
49
|
+
*/
|
|
50
|
+
const bannerRules = ((_a = cssText.match(/[^{}]*\.blocked-banner[^{}]*\{[^}]*\}/g)) !== null && _a !== void 0 ? _a : []).join('\n');
|
|
51
|
+
/** Declarations of the bare-class rule — the one that applies while empty. */
|
|
52
|
+
const emptyStateRule = (_c = (_b = cssText.match(/[\s{}](\.blocked-banner)\s*\{([^}]*)\}/)) === null || _b === void 0 ? void 0 : _b[2]) !== null && _c !== void 0 ? _c : '';
|
|
53
|
+
suite('the banner rules were extracted as text (guards the assertions below)', () => {
|
|
54
|
+
assert.ok(cssText.includes('.blocked-banner'), 'the banner rules are in the sheet');
|
|
55
|
+
assert.ok(bannerRules.includes('.is-visible'), 'the extraction found the modifier rules');
|
|
56
|
+
assert.ok(emptyStateRule.length > 0, 'and isolated the bare-class rule');
|
|
57
|
+
});
|
|
58
|
+
suite('the banner consumes design-system tokens without dead fallbacks', () => {
|
|
59
|
+
assert.not.ok(bannerRules.includes('var(--neutral-layer-2,'), '--neutral-layer-2 has a DS default and is used unfallbacked everywhere else here');
|
|
60
|
+
assert.not.ok(bannerRules.includes('var(--warning-color,'), '--warning-color has a DS default and is used unfallbacked by toast/badge styles');
|
|
61
|
+
});
|
|
62
|
+
suite('the blocked banner keeps its unblocked container visually inert', () => {
|
|
63
|
+
// `::part(blocked-banner)` matches at all times, so the empty state must set
|
|
64
|
+
// nothing that reserves space — and must not remove the container from the
|
|
65
|
+
// accessibility tree, which is where the `role="status"` region is registered.
|
|
66
|
+
//
|
|
67
|
+
// Asserted against the bare-class rule's own declarations rather than by
|
|
68
|
+
// grepping for a `:empty` selector nobody wrote: this catches the mistake as it
|
|
69
|
+
// would actually be made — a padding or border creeping onto the always-present
|
|
70
|
+
// container — instead of one specific spelling of it.
|
|
71
|
+
//
|
|
72
|
+
// `margin-block` rather than `margin`: the shared composer-width group sets
|
|
73
|
+
// `margin-inline: auto` on the banner along with the rest of the composer
|
|
74
|
+
// column, which centres it and reserves nothing.
|
|
75
|
+
assert.ok(cssText.includes('.blocked-banner.is-visible'), 'visuals hang off is-visible');
|
|
76
|
+
for (const property of ['display: none', 'padding', 'border', 'background', 'margin-block']) {
|
|
77
|
+
assert.not.ok(emptyStateRule.includes(property), `the always-present container must not set ${property} — that is .is-visible's job`);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
suite('partial exhaustion has its own modifier, layered on the visible state', () => {
|
|
81
|
+
// `.is-visible` carries the layout and surface for BOTH states; `.is-partial`
|
|
82
|
+
// only softens it. Additive, never a replacement — so host CSS keyed on the
|
|
83
|
+
// visible state needs to know nothing about partial exhaustion.
|
|
84
|
+
assert.ok(cssText.includes('.blocked-banner.is-partial'), 'the partial modifier exists');
|
|
85
|
+
});
|
|
86
|
+
suite.run();
|
|
@@ -228,6 +228,109 @@ const restoringTemplate = html `
|
|
|
228
228
|
<span class="compacting-caption">Restoring…</span>
|
|
229
229
|
</div>
|
|
230
230
|
`;
|
|
231
|
+
/**
|
|
232
|
+
* Class list for the banner below, joined rather than interpolated so an
|
|
233
|
+
* inapplicable modifier contributes nothing. Two interpolations directly in the
|
|
234
|
+
* attribute emitted `class="blocked-banner "` in the common (unblocked) case —
|
|
235
|
+
* harmless to the browser, but it shows up in every DOM snapshot and every
|
|
236
|
+
* innerHTML assertion a host writes against this element.
|
|
237
|
+
*
|
|
238
|
+
* The two modifiers name what they actually gate, which is why neither is
|
|
239
|
+
* `is-blocked`: `is-visible` means the banner has something to say, and that
|
|
240
|
+
* includes PARTIAL exhaustion — one vendor walled, composer still live, `blocked`
|
|
241
|
+
* false. `is-partial` then softens the treatment for exactly that case. Naming
|
|
242
|
+
* the first after `blocked` read as a contradiction beside the second, and made
|
|
243
|
+
* the styles say `.blocked-banner.is-blocked` to mean "visible".
|
|
244
|
+
*
|
|
245
|
+
* Exported for the unit test that pins the attribute; not part of the element
|
|
246
|
+
* API.
|
|
247
|
+
*
|
|
248
|
+
* @internal
|
|
249
|
+
*/
|
|
250
|
+
export const blockedBannerClasses = (x) => [
|
|
251
|
+
'blocked-banner',
|
|
252
|
+
x.bannerVisible ? 'is-visible' : '',
|
|
253
|
+
x.bannerVisible && !x.blocked ? 'is-partial' : '',
|
|
254
|
+
]
|
|
255
|
+
.filter(Boolean)
|
|
256
|
+
.join(' ');
|
|
257
|
+
/**
|
|
258
|
+
* Id of the blocked banner, referenced by the composer controls'
|
|
259
|
+
* `aria-describedby`. Shadow-DOM-scoped, so a fixed string cannot collide with
|
|
260
|
+
* the host page — and IDREF resolution is same-root, which is exactly where both
|
|
261
|
+
* ends of this reference live.
|
|
262
|
+
*
|
|
263
|
+
* @internal
|
|
264
|
+
*/
|
|
265
|
+
export const BLOCKED_BANNER_ID = 'blocked-banner';
|
|
266
|
+
/**
|
|
267
|
+
* `aria-describedby` for the composer's textarea, send button and attach button:
|
|
268
|
+
* the banner's id whenever the banner has something to say, otherwise `null` (so
|
|
269
|
+
* the attribute is omitted rather than emitted empty).
|
|
270
|
+
*
|
|
271
|
+
* Keyed on `bannerVisible`, NOT on `blocked`, and that is the point. The
|
|
272
|
+
* PARTIAL state — some vendor walled, composer still live — is the state this
|
|
273
|
+
* feature exists to create, and it was the one state with no accessible
|
|
274
|
+
* explanation at all: `aria-disabled` and `aria-label` bind only on `blocked`, and
|
|
275
|
+
* a live composer keeps the host's own placeholder, so a screen-reader user
|
|
276
|
+
* arriving at the textarea heard "Type a message" with no hint that the next turn
|
|
277
|
+
* might be refused. The banner's `role="status"` announces the text when it
|
|
278
|
+
* CHANGES; this is what makes the same explanation reachable afterwards, on
|
|
279
|
+
* demand, from the control it is about.
|
|
280
|
+
*
|
|
281
|
+
* Applied in the fully blocked state too, where it is additive: the `aria-label`
|
|
282
|
+
* there states the reason as the control's name, and this restates it as its
|
|
283
|
+
* description for the send/attach buttons, which carry neither.
|
|
284
|
+
*
|
|
285
|
+
* Exported for the unit test that pins it; not part of the element API.
|
|
286
|
+
*
|
|
287
|
+
* @internal
|
|
288
|
+
*/
|
|
289
|
+
export const composerDescribedBy = (x) => x.bannerVisible ? BLOCKED_BANNER_ID : null;
|
|
290
|
+
/**
|
|
291
|
+
* Persistent banner shown above the composer while a backend condition blocks
|
|
292
|
+
* the assistant — today an exhausted AI budget (GENC-1464).
|
|
293
|
+
*
|
|
294
|
+
* Deliberately NOT a transcript replacement like `compactingTemplate` /
|
|
295
|
+
* `restoringTemplate`: those cover a transient rewrite where the history is
|
|
296
|
+
* mid-flight and unsafe to show, whereas a block is indefinite. Hiding the
|
|
297
|
+
* conversation behind it would strand the user's work behind a wall they
|
|
298
|
+
* cannot clear, so the banner sits alongside a fully readable transcript.
|
|
299
|
+
*
|
|
300
|
+
* `role="status"` (not `alert`) — it is a standing condition the user can also
|
|
301
|
+
* see in the disabled composer, not an interruption.
|
|
302
|
+
*
|
|
303
|
+
* **Rendered unconditionally, empty when unblocked.** This is an accessibility
|
|
304
|
+
* requirement, not a stylistic choice: `role="status"` announces MUTATIONS to a
|
|
305
|
+
* region the assistive technology was already observing. A region INSERTED into
|
|
306
|
+
* the DOM already carrying its text is widely missed across NVDA, JAWS and
|
|
307
|
+
* VoiceOver — and it is the only announcement a screen-reader user gets, since
|
|
308
|
+
* the textarea simultaneously flips to `disabled`, which drops it out of the tab
|
|
309
|
+
* order and means the swapped "AI usage limit reached" placeholder is never
|
|
310
|
+
* voiced (`maybeAutoFocusChatInput` also no-ops against a disabled control). So
|
|
311
|
+
* the region stays mounted and only its TEXT changes.
|
|
312
|
+
*
|
|
313
|
+
* It is deliberately **not** collapsed with `display: none` when unblocked —
|
|
314
|
+
* that would drop it out of the accessibility tree and make the flip an
|
|
315
|
+
* insertion again, defeating the point. Instead the visual treatment (padding,
|
|
316
|
+
* border, background) hangs off the `is-visible` class, so the empty container
|
|
317
|
+
* occupies no space while remaining a live, registered region.
|
|
318
|
+
*
|
|
319
|
+
* It carries {@link BLOCKED_BANNER_ID} so the composer controls can point
|
|
320
|
+
* `aria-describedby` at it — see {@link composerDescribedBy}.
|
|
321
|
+
*/
|
|
322
|
+
const blockedBannerTemplate = html `
|
|
323
|
+
<div
|
|
324
|
+
id="${BLOCKED_BANNER_ID}"
|
|
325
|
+
class="${blockedBannerClasses}"
|
|
326
|
+
part="blocked-banner"
|
|
327
|
+
role="status"
|
|
328
|
+
>
|
|
329
|
+
${when((x) => x.bannerVisible, html `
|
|
330
|
+
<span class="blocked-banner-text">${(x) => x.effectiveBlockedReason}</span>
|
|
331
|
+
`)}
|
|
332
|
+
</div>
|
|
333
|
+
`;
|
|
231
334
|
// ─── Public factory ───────────────────────────────────────────────────────────
|
|
232
335
|
/** @internal */
|
|
233
336
|
export const FoundationAiAssistantTemplate = (designSystemPrefix) => {
|
|
@@ -337,13 +440,18 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
|
|
|
337
440
|
`)}
|
|
338
441
|
</${buttonTag}>
|
|
339
442
|
`;
|
|
443
|
+
// The attach button shares the textarea's disable gate. Attaching a file that
|
|
444
|
+
// can never be sent is a dead end — and `blocked`, `compacting` and `restoring`
|
|
445
|
+
// were all missing from it, so files could be attached during a compaction or
|
|
446
|
+
// against an exhausted budget and then never sent.
|
|
340
447
|
const attachButtonTemplate = html `
|
|
341
448
|
<${buttonTag}
|
|
342
449
|
class="attach-button"
|
|
343
450
|
part="attach-button"
|
|
344
451
|
appearance="stealth"
|
|
345
452
|
title=${(x) => { var _a; return `Attach file (${(_a = x.chatConfig.ui) === null || _a === void 0 ? void 0 : _a.acceptedFiles})`; }}
|
|
346
|
-
?disabled=${(x) => x.busy}
|
|
453
|
+
?disabled=${(x) => x.busy || x.compacting || x.restoring || x.blocked}
|
|
454
|
+
aria-describedby=${composerDescribedBy}
|
|
347
455
|
@click=${(x) => x.triggerFileInput()}
|
|
348
456
|
><${iconTag} name="paperclip"></${iconTag}></${buttonTag}>
|
|
349
457
|
`;
|
|
@@ -548,13 +656,18 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
|
|
|
548
656
|
`)}
|
|
549
657
|
</div>
|
|
550
658
|
`)}
|
|
551
|
-
${when((x) => { var _a; return ((_a = x.chatConfig.suggestions) === null || _a === void 0 ? void 0 : _a.behavior) !== 'never'; }, html `
|
|
659
|
+
${when((x) => { var _a; return ((_a = x.chatConfig.suggestions) === null || _a === void 0 ? void 0 : _a.behavior) !== 'never' && !x.suggestionsBlocked; }, html `
|
|
552
660
|
<chat-suggestions
|
|
553
661
|
:state="${(x) => x.suggestionsState}"
|
|
554
662
|
@suggestion-clicked="${(x, c) => x.handleSuggestionClick(c.event.detail)}"
|
|
555
663
|
></chat-suggestions>
|
|
556
664
|
`)}
|
|
557
665
|
${when((x) => x.agentPickerEnabled && x.agentPickerOpen, agentPickerPanelTemplate)}${when((x) => x.sessionMenuOpen && !x.busy, sessionMenuPanelTemplate)}
|
|
666
|
+
${
|
|
667
|
+
/* Rendered above the composer and independently of it — a host that hides the
|
|
668
|
+
built-in composer (`composerHiddenByConfig`) still needs to be told why its own
|
|
669
|
+
input is being refused. */ ''}
|
|
670
|
+
${blockedBannerTemplate}
|
|
558
671
|
${when((x) => !x.composerHiddenByConfig &&
|
|
559
672
|
!(x.busy && x.effectiveChatInputDuringExecution === 'hidden'), html `
|
|
560
673
|
${when((x) => x.isComposerResizable, html `
|
|
@@ -579,7 +692,10 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
|
|
|
579
692
|
style=${(x) => x.composerHeightStyle}
|
|
580
693
|
placeholder=${(x) => x.effectivePlaceholder}
|
|
581
694
|
:value=${(x) => x.inputValue}
|
|
582
|
-
?disabled=${(x) => x.busy || x.compacting || x.restoring}
|
|
695
|
+
?disabled=${(x) => x.busy || x.compacting || x.restoring || x.blocked}
|
|
696
|
+
aria-disabled=${(x) => (x.blocked ? 'true' : null)}
|
|
697
|
+
aria-label=${(x) => (x.blocked ? x.effectiveBlockedReason : null)}
|
|
698
|
+
aria-describedby=${composerDescribedBy}
|
|
583
699
|
@input=${(x, c) => (x.inputValue = c.event.target.value)}
|
|
584
700
|
@keydown=${(x, c) => {
|
|
585
701
|
if (c.event.key === 'Enter' &&
|
|
@@ -610,7 +726,8 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
|
|
|
610
726
|
<${buttonTag}
|
|
611
727
|
class="send-button"
|
|
612
728
|
part="send-button"
|
|
613
|
-
?disabled=${(x) => x.busy || x.compacting || x.restoring || (!x.inputValue.trim() && !x.attachments.length)}
|
|
729
|
+
?disabled=${(x) => x.busy || x.compacting || x.restoring || x.blocked || (!x.inputValue.trim() && !x.attachments.length)}
|
|
730
|
+
aria-describedby=${composerDescribedBy}
|
|
614
731
|
@click=${(x) => x.handleSendClick()}
|
|
615
732
|
>Send</${buttonTag}>
|
|
616
733
|
</div>
|
|
@@ -29,6 +29,11 @@ export function createDefaultSessionState() {
|
|
|
29
29
|
sessionMenuOpen: false,
|
|
30
30
|
compacting: false,
|
|
31
31
|
restoring: false,
|
|
32
|
+
blocked: false,
|
|
33
|
+
blockedReason: null,
|
|
34
|
+
blockedVendors: [],
|
|
35
|
+
sweptVendors: [],
|
|
36
|
+
blockedVendorReasons: {},
|
|
32
37
|
inputValue: '',
|
|
33
38
|
liveSubAgentTrace: [],
|
|
34
39
|
liveSubAgentName: null,
|
|
@@ -106,6 +111,98 @@ export const aiAssistantSlice = createSlice({
|
|
|
106
111
|
setRestoring(state, action) {
|
|
107
112
|
state.restoring = action.payload;
|
|
108
113
|
},
|
|
114
|
+
/**
|
|
115
|
+
* Latch (or release) the backend-blocked state.
|
|
116
|
+
*
|
|
117
|
+
* `reason` is only meaningful while blocking, and it distinguishes three
|
|
118
|
+
* cases rather than two:
|
|
119
|
+
*
|
|
120
|
+
* - **omitted** — a re-latch that leaves the existing explanation intact.
|
|
121
|
+
* Load-bearing: the block has two writers (the host, and the driver's
|
|
122
|
+
* automatic latch), and the driver's write lands AFTER a bus-subscribed
|
|
123
|
+
* host has already set its own detailed copy. Treating "no reason" as
|
|
124
|
+
* "clear the reason" made that sequence replace the host's figures with
|
|
125
|
+
* generic copy every time, deterministically.
|
|
126
|
+
* - **a string** — replaces the explanation.
|
|
127
|
+
* - **`null`** — explicitly clears the explanation while staying blocked;
|
|
128
|
+
* the banner falls back to the element's default copy.
|
|
129
|
+
*
|
|
130
|
+
* Unblocking always clears it, so a stale explanation can never outlive the
|
|
131
|
+
* condition it described.
|
|
132
|
+
*
|
|
133
|
+
* Unblocking **also clears every per-vendor latch**, and that is the single
|
|
134
|
+
* rule that keeps per-vendor budgets non-breaking. A host asserting "the wall
|
|
135
|
+
* is gone" (the documented pre-flight, migration-GENC-1464 Option B) must not be
|
|
136
|
+
* silently overruled by a driver latch it cannot see; without this, the same
|
|
137
|
+
* `setBlocked(false)` that used to unblock would leave the composer locked.
|
|
138
|
+
*/
|
|
139
|
+
setBlocked(state, action) {
|
|
140
|
+
state.blocked = action.payload.blocked;
|
|
141
|
+
if (!action.payload.blocked) {
|
|
142
|
+
state.blockedReason = null;
|
|
143
|
+
state.blockedVendors = [];
|
|
144
|
+
state.sweptVendors = [];
|
|
145
|
+
state.blockedVendorReasons = {};
|
|
146
|
+
}
|
|
147
|
+
else if (action.payload.reason !== undefined) {
|
|
148
|
+
state.blockedReason = action.payload.reason;
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
/**
|
|
152
|
+
* Write the banner explanation without touching the flag.
|
|
153
|
+
*
|
|
154
|
+
* Separate from {@link setBlocked} rather than expressed as
|
|
155
|
+
* `setBlocked({ blocked: current, reason })`, because the element's public
|
|
156
|
+
* `blocked` is now DERIVED: re-latching with the derived value would pass
|
|
157
|
+
* `false` for a partially-walled session and so clear every per-vendor latch
|
|
158
|
+
* as a side effect of setting a string.
|
|
159
|
+
*/
|
|
160
|
+
setBlockedReason(state, action) {
|
|
161
|
+
state.blockedReason = action.payload;
|
|
162
|
+
},
|
|
163
|
+
/**
|
|
164
|
+
* Wall (or release) a single vendor's budget.
|
|
165
|
+
*
|
|
166
|
+
* The `reason` trichotomy mirrors {@link setBlocked}'s exactly, and for the
|
|
167
|
+
* same reason: the same two-writer race (the driver's latch landing after a
|
|
168
|
+
* bus-subscribed host has written its own copy) applies per vendor.
|
|
169
|
+
*
|
|
170
|
+
* Adding an already-walled vendor is idempotent — the array is a set with an
|
|
171
|
+
* order, so the vendor keeps the position it was first walled at.
|
|
172
|
+
*/
|
|
173
|
+
setVendorBlocked(state, action) {
|
|
174
|
+
const { vendor, blocked, reason, swept } = action.payload;
|
|
175
|
+
const index = state.blockedVendors.indexOf(vendor);
|
|
176
|
+
const sweptIndex = state.sweptVendors.indexOf(vendor);
|
|
177
|
+
if (blocked) {
|
|
178
|
+
if (index < 0)
|
|
179
|
+
state.blockedVendors.push(vendor);
|
|
180
|
+
if (swept) {
|
|
181
|
+
// Provenance is FIRST-writer-wins: marking swept only records a wall
|
|
182
|
+
// this call itself created. The sweep site already skips walled
|
|
183
|
+
// vendors, so this guard is belt-and-braces against a future caller
|
|
184
|
+
// downgrading a refused wall to "just the proxy's word".
|
|
185
|
+
if (index < 0 && sweptIndex < 0)
|
|
186
|
+
state.sweptVendors.push(vendor);
|
|
187
|
+
}
|
|
188
|
+
else if (sweptIndex >= 0) {
|
|
189
|
+
state.sweptVendors.splice(sweptIndex, 1);
|
|
190
|
+
}
|
|
191
|
+
if (reason === null) {
|
|
192
|
+
delete state.blockedVendorReasons[vendor];
|
|
193
|
+
}
|
|
194
|
+
else if (reason !== undefined) {
|
|
195
|
+
state.blockedVendorReasons[vendor] = reason;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
if (index >= 0)
|
|
200
|
+
state.blockedVendors.splice(index, 1);
|
|
201
|
+
if (sweptIndex >= 0)
|
|
202
|
+
state.sweptVendors.splice(sweptIndex, 1);
|
|
203
|
+
delete state.blockedVendorReasons[vendor];
|
|
204
|
+
}
|
|
205
|
+
},
|
|
109
206
|
setInputValue(state, action) {
|
|
110
207
|
state.inputValue = action.payload;
|
|
111
208
|
},
|
|
@@ -147,21 +244,62 @@ export const aiAssistantSlice = createSlice({
|
|
|
147
244
|
},
|
|
148
245
|
/**
|
|
149
246
|
* Wipe the session back to a fresh default — the "Clear" / "New chat" action.
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
247
|
+
*
|
|
248
|
+
* Three categories survive, for three different reasons — keep them distinct:
|
|
249
|
+
*
|
|
250
|
+
* 1. The user's **display preferences** (tool-call / thinking / narration /
|
|
251
|
+
* agent-switch visibility + animations). They are settings, not
|
|
252
|
+
* conversation state, and are persisted independently of the snapshot
|
|
253
|
+
* (GENC-1351). Without this they'd flip to their all-off defaults in the
|
|
254
|
+
* live store on Clear, since `clearSession` re-applies no baseline and the
|
|
255
|
+
* idempotent setters suppress a re-apply.
|
|
256
|
+
* 2. The **backend block** (`blocked` / `blockedReason`, and the per-vendor
|
|
257
|
+
* latches beside them). Not a preference at
|
|
258
|
+
* all — a standing backend condition that Clear cannot change. It is here
|
|
259
|
+
* because starting a new chat does not refill the AI budget, so clearing
|
|
260
|
+
* it would hand the user a working-looking composer that fails on the very
|
|
261
|
+
* next send.
|
|
262
|
+
* 3. The **provider statuses**. They describe the REGISTRY, not the
|
|
263
|
+
* conversation — provider registration is static for the store's lifetime
|
|
264
|
+
* and this snapshot is refreshed from the registry, never from a turn. They
|
|
265
|
+
* are session state only because that is where the element's store lives.
|
|
266
|
+
*
|
|
267
|
+
* Dropping them on Clear is not cosmetic: the element derives `blocked`
|
|
268
|
+
* from "every REACHABLE vendor is walled", and an empty reachable set fails
|
|
269
|
+
* safe to "blocked". So wiping the statuses while keeping the per-vendor
|
|
270
|
+
* latches (category 2) escalated one walled vendor out of two into a fully
|
|
271
|
+
* locked composer, and nothing reloads them — `loadProviderStatuses()` runs
|
|
272
|
+
* only on connect/session-switch and from the observable-registry
|
|
273
|
+
* subscription, so a host with a static registry stayed locked until a
|
|
274
|
+
* remount. Preserving is the fix rather than re-loading after reset,
|
|
275
|
+
* because the reload is async: it would still flash a fully locked composer
|
|
276
|
+
* for a turn of the event loop, and would break a host whose registry is
|
|
277
|
+
* immutable (nothing to re-listen to) or whose `listStatuses()` rejects.
|
|
278
|
+
*
|
|
279
|
+
* Stated separately so a future contributor deciding what belongs in this
|
|
280
|
+
* list does not "tidy" the block out on the grounds that it is not a display
|
|
281
|
+
* preference.
|
|
155
282
|
*/
|
|
156
283
|
resetSession(state) {
|
|
157
|
-
|
|
284
|
+
// `satisfies` gives this literal excess-property checking and per-field type
|
|
285
|
+
// checking, neither of which `Object.assign` provides — its source parameter
|
|
286
|
+
// is structurally permissive, so a typo (`blockedResaon`) would compile
|
|
287
|
+
// clean, silently stop preserving the real key, AND add a junk key to the
|
|
288
|
+
// draft that then leaks into serialised state.
|
|
289
|
+
const preservedAcrossReset = {
|
|
158
290
|
showToolCalls: state.showToolCalls,
|
|
159
291
|
showThinkingSteps: state.showThinkingSteps,
|
|
160
292
|
showNarration: state.showNarration,
|
|
161
293
|
showAgentSwitchIndicator: state.showAgentSwitchIndicator,
|
|
162
294
|
enabledAnimations: state.enabledAnimations,
|
|
295
|
+
providerStatuses: state.providerStatuses,
|
|
296
|
+
blocked: state.blocked,
|
|
297
|
+
blockedReason: state.blockedReason,
|
|
298
|
+
blockedVendors: state.blockedVendors,
|
|
299
|
+
sweptVendors: state.sweptVendors,
|
|
300
|
+
blockedVendorReasons: state.blockedVendorReasons,
|
|
163
301
|
};
|
|
164
|
-
Object.assign(state, createDefaultSessionState(),
|
|
302
|
+
Object.assign(state, createDefaultSessionState(), preservedAcrossReset);
|
|
165
303
|
},
|
|
166
304
|
},
|
|
167
305
|
selectors: {},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
2
2
|
import { aiAssistantSlice, createDefaultSessionState, } from './ai-assistant-slice';
|
|
3
3
|
// GENC-1351 §5.6 / Phase 1: the new bulk loadSession + resetSession reducers.
|
|
4
|
-
const { loadSession, resetSession, setMessages, setState, setSessionMenuOpen, setShowToolCalls, setShowThinkingSteps, setShowNarration, setShowAgentSwitchIndicator, setEnabledAnimations, } = aiAssistantSlice.actions;
|
|
4
|
+
const { loadSession, resetSession, setBlocked, setBlockedReason, setVendorBlocked, setInputValue, setMessages, setState, setSessionMenuOpen, setShowToolCalls, setShowThinkingSteps, setShowNarration, setShowAgentSwitchIndicator, setEnabledAnimations, } = aiAssistantSlice.actions;
|
|
5
5
|
const reduce = aiAssistantSlice.reducer;
|
|
6
6
|
const Suite = createLogicSuite('ai-assistant-slice-persistence');
|
|
7
7
|
Suite('loadSession hydrates the restorable fields and forces idle', () => {
|
|
@@ -89,4 +89,141 @@ Suite('setSessionMenuOpen toggles the lifecycle-menu flag; resetSession clears i
|
|
|
89
89
|
const cleared = reduce(state, resetSession());
|
|
90
90
|
assert.is(cleared.sessionMenuOpen, false);
|
|
91
91
|
});
|
|
92
|
+
// GENC-1464: the backend-blocked latch (exhausted AI budget).
|
|
93
|
+
Suite('setBlocked latches the flag and its reason', () => {
|
|
94
|
+
let state = createDefaultSessionState();
|
|
95
|
+
assert.is(state.blocked, false);
|
|
96
|
+
assert.is(state.blockedReason, null);
|
|
97
|
+
state = reduce(state, setBlocked({ blocked: true }));
|
|
98
|
+
assert.is(state.blocked, true);
|
|
99
|
+
assert.is(state.blockedReason, null, 'no reason given → null, so the element uses its default');
|
|
100
|
+
state = reduce(state, setBlocked({ blocked: true, reason: 'Budget exhausted — contact billing' }));
|
|
101
|
+
assert.is(state.blockedReason, 'Budget exhausted — contact billing');
|
|
102
|
+
});
|
|
103
|
+
Suite('unblocking always clears the reason', () => {
|
|
104
|
+
// A stale explanation must never outlive the condition it described.
|
|
105
|
+
let state = reduce(createDefaultSessionState(), setBlocked({ blocked: true, reason: 'gone' }));
|
|
106
|
+
state = reduce(state, setBlocked({ blocked: false }));
|
|
107
|
+
assert.is(state.blocked, false);
|
|
108
|
+
assert.is(state.blockedReason, null);
|
|
109
|
+
});
|
|
110
|
+
Suite('resetSession preserves the block — "New chat" does not refill the budget', () => {
|
|
111
|
+
// Deliberately unlike sessionMenuOpen/compacting above: this is a backend
|
|
112
|
+
// condition, not conversation state, so Clear must not hand the user a
|
|
113
|
+
// working-looking composer that fails on the next send.
|
|
114
|
+
let state = reduce(createDefaultSessionState(), setBlocked({ blocked: true, reason: 'no funds' }));
|
|
115
|
+
state = reduce(state, setMessages([{ role: 'user', content: 'x' }]));
|
|
116
|
+
const cleared = reduce(state, resetSession());
|
|
117
|
+
assert.is(cleared.messages.length, 0, 'the transcript is still cleared');
|
|
118
|
+
assert.is(cleared.blocked, true, 'the block survives Clear');
|
|
119
|
+
assert.is(cleared.blockedReason, 'no funds', 'and so does its reason');
|
|
120
|
+
});
|
|
121
|
+
Suite('re-latching without a reason preserves the host explanation', () => {
|
|
122
|
+
// The race this fixes is deterministic, not theoretical: `tool-loop-end` is
|
|
123
|
+
// published from the driver's `finally`, i.e. BEFORE `sendMessage()` resolves.
|
|
124
|
+
// So a bus-subscribed host (what the migration guide's Option C recommends)
|
|
125
|
+
// writes its detailed copy first, and the element's own latch — which may have
|
|
126
|
+
// no figures to offer — lands a moment later. Treating an OMITTED reason as
|
|
127
|
+
// "clear it" replaced the host's copy with generic copy every single time.
|
|
128
|
+
let state = reduce(createDefaultSessionState(), setBlocked({ blocked: true, reason: 'contact ops@acme.com' }));
|
|
129
|
+
state = reduce(state, setBlocked({ blocked: true }));
|
|
130
|
+
assert.is(state.blocked, true);
|
|
131
|
+
assert.is(state.blockedReason, 'contact ops@acme.com', 'the host copy survives the re-latch');
|
|
132
|
+
});
|
|
133
|
+
Suite('reason:null clears the explanation while staying blocked', () => {
|
|
134
|
+
// The explicit escape hatch the omitted case gives up: `null` still clears.
|
|
135
|
+
let state = reduce(createDefaultSessionState(), setBlocked({ blocked: true, reason: 'stale copy' }));
|
|
136
|
+
state = reduce(state, setBlocked({ blocked: true, reason: null }));
|
|
137
|
+
assert.is(state.blocked, true);
|
|
138
|
+
assert.is(state.blockedReason, null, 'the banner falls back to the default copy');
|
|
139
|
+
});
|
|
140
|
+
Suite('resetSession still resets everything NOT on the preserved list', () => {
|
|
141
|
+
// Pins the boundary from the other side, so the preserved list cannot quietly
|
|
142
|
+
// grow: these are conversation/UI state and Clear must wipe them.
|
|
143
|
+
let state = reduce(createDefaultSessionState(), setBlocked({ blocked: true, reason: 'r' }));
|
|
144
|
+
state = reduce(state, setSessionMenuOpen(true));
|
|
145
|
+
state = reduce(state, setInputValue('half-typed message'));
|
|
146
|
+
const cleared = reduce(state, resetSession());
|
|
147
|
+
assert.is(cleared.inputValue, '', 'the composer draft is reset');
|
|
148
|
+
assert.is(cleared.sessionMenuOpen, false, 'the panel flag is reset');
|
|
149
|
+
assert.is(cleared.blocked, true, '…while the block is still preserved');
|
|
150
|
+
});
|
|
151
|
+
// ── Per-vendor latches (GENC-1464 per-vendor budgets) ──────────────────────────
|
|
152
|
+
Suite('setVendorBlocked walls one vendor and keeps the wall order', () => {
|
|
153
|
+
let state = reduce(createDefaultSessionState(), setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: 'anthropic copy' }));
|
|
154
|
+
assert.equal(state.blockedVendors, ['anthropic']);
|
|
155
|
+
assert.equal(state.blockedVendorReasons, { anthropic: 'anthropic copy' });
|
|
156
|
+
assert.is(state.blocked, false, 'the vendor-agnostic flag is untouched');
|
|
157
|
+
state = reduce(state, setVendorBlocked({ vendor: 'gemini', blocked: true }));
|
|
158
|
+
assert.equal(state.blockedVendors, ['anthropic', 'gemini'], 'order is the order they were walled');
|
|
159
|
+
});
|
|
160
|
+
Suite('walling an already-walled vendor is idempotent', () => {
|
|
161
|
+
let state = reduce(createDefaultSessionState(), setVendorBlocked({ vendor: 'gemini', blocked: true, reason: 'first' }));
|
|
162
|
+
state = reduce(state, setVendorBlocked({ vendor: 'gemini', blocked: true, reason: 'second' }));
|
|
163
|
+
assert.equal(state.blockedVendors, ['gemini'], 'no duplicate entry');
|
|
164
|
+
assert.is(state.blockedVendorReasons.gemini, 'second');
|
|
165
|
+
});
|
|
166
|
+
Suite('the per-vendor reason honours the same three cases as setBlocked', () => {
|
|
167
|
+
// The same two-writer race (driver latch landing after a bus-subscribed host)
|
|
168
|
+
// applies per vendor, so "omitted keeps" has to hold here too.
|
|
169
|
+
let state = reduce(createDefaultSessionState(), setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: 'host copy' }));
|
|
170
|
+
state = reduce(state, setVendorBlocked({ vendor: 'anthropic', blocked: true }));
|
|
171
|
+
assert.is(state.blockedVendorReasons.anthropic, 'host copy', 'omitted keeps');
|
|
172
|
+
state = reduce(state, setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: null }));
|
|
173
|
+
assert.is(state.blockedVendorReasons.anthropic, undefined, 'null clears');
|
|
174
|
+
});
|
|
175
|
+
Suite('releasing a vendor drops it and its copy', () => {
|
|
176
|
+
let state = reduce(createDefaultSessionState(), setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: 'r' }));
|
|
177
|
+
state = reduce(state, setVendorBlocked({ vendor: 'gemini', blocked: true }));
|
|
178
|
+
state = reduce(state, setVendorBlocked({ vendor: 'anthropic', blocked: false }));
|
|
179
|
+
assert.equal(state.blockedVendors, ['gemini']);
|
|
180
|
+
assert.is(state.blockedVendorReasons.anthropic, undefined);
|
|
181
|
+
});
|
|
182
|
+
Suite('setBlocked(false) also clears every per-vendor latch', () => {
|
|
183
|
+
// THE back-compat rule. A host asserting "the wall is gone" after its own
|
|
184
|
+
// pre-flight (migration-GENC-1464 Option B) must not be overruled by a driver
|
|
185
|
+
// latch it cannot see — without this, per-vendor would silently make the
|
|
186
|
+
// documented unblock stop working.
|
|
187
|
+
let state = reduce(createDefaultSessionState(), setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: 'r' }));
|
|
188
|
+
state = reduce(state, setBlocked({ blocked: true, reason: 'global' }));
|
|
189
|
+
state = reduce(state, setBlocked({ blocked: false }));
|
|
190
|
+
assert.is(state.blocked, false);
|
|
191
|
+
assert.is(state.blockedReason, null);
|
|
192
|
+
assert.equal(state.blockedVendors, []);
|
|
193
|
+
assert.equal(state.blockedVendorReasons, {});
|
|
194
|
+
});
|
|
195
|
+
Suite('setBlockedReason writes the copy without disturbing any latch', () => {
|
|
196
|
+
// It exists precisely so the element's `blockedReason` setter cannot route
|
|
197
|
+
// through `setBlocked(false, …)` — which would now clear the vendor latches.
|
|
198
|
+
let state = reduce(createDefaultSessionState(), setVendorBlocked({ vendor: 'gemini', blocked: true }));
|
|
199
|
+
state = reduce(state, setBlockedReason('host copy'));
|
|
200
|
+
assert.is(state.blockedReason, 'host copy');
|
|
201
|
+
assert.is(state.blocked, false, 'the vendor-agnostic flag is untouched');
|
|
202
|
+
assert.equal(state.blockedVendors, ['gemini'], 'and so are the per-vendor latches');
|
|
203
|
+
});
|
|
204
|
+
Suite('resetSession preserves the per-vendor latches too', () => {
|
|
205
|
+
let state = reduce(createDefaultSessionState(), setVendorBlocked({ vendor: 'anthropic', blocked: true, reason: 'no funds' }));
|
|
206
|
+
state = reduce(state, setMessages([{ role: 'user', content: 'hi' }]));
|
|
207
|
+
const cleared = reduce(state, resetSession());
|
|
208
|
+
assert.equal(cleared.messages, [], 'the conversation is wiped');
|
|
209
|
+
assert.equal(cleared.blockedVendors, ['anthropic'], 'Clear does not refill a vendor budget');
|
|
210
|
+
assert.equal(cleared.blockedVendorReasons, { anthropic: 'no funds' });
|
|
211
|
+
});
|
|
212
|
+
// `resetSession` preserves its keys through `Object.assign`, whose source parameter
|
|
213
|
+
// is structurally permissive: a typo like `blockedResaon` would compile clean,
|
|
214
|
+
// silently stop preserving the real key, AND leak a junk key into serialised state.
|
|
215
|
+
// The reducer therefore annotates its literal `satisfies
|
|
216
|
+
// Partial<AiAssistantSessionState>`, which is a COMPILE-time guarantee — so the
|
|
217
|
+
// durable proof is a type fixture, not a runtime assertion.
|
|
218
|
+
//
|
|
219
|
+
// This mirrors the reducer's clause on the same typo. If `satisfies` ever stopped
|
|
220
|
+
// rejecting excess properties (a TS behaviour change, or someone widening the
|
|
221
|
+
// annotated type), the `@ts-expect-error` below becomes unused and the BUILD fails
|
|
222
|
+
// — which is exactly the signal wanted. The runtime suites above are its
|
|
223
|
+
// behavioural companions: they pin the preserved list from both sides.
|
|
224
|
+
const _typoIsRejectedAtCompileTime = {
|
|
225
|
+
// @ts-expect-error — an excess/misspelled key must be rejected by `satisfies`
|
|
226
|
+
blockedResaon: null,
|
|
227
|
+
};
|
|
228
|
+
void _typoIsRejectedAtCompileTime;
|
|
92
229
|
Suite.run();
|