@immediately-run/grove 0.1.5 → 0.1.8

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/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # @immediately-run/grove — the viewer kit for directory-as-content wikis
2
2
 
3
- > The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.1.5)
3
+ > The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.1.8)
4
4
 
5
5
  Grove is NOT a wiki engine: routing, MDX compilation, the frontmatter index, link
6
6
  spaces and heading anchors live in the sandbox + `@immediately-run/sdk`. What this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@immediately-run/grove",
3
- "version": "0.1.5",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "main": "src/main.tsx",
6
6
  "immediately.run": {
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@immediately-run/mdx-plugins": "0.7.1",
44
- "@immediately-run/sdk": "^0.67.0",
44
+ "@immediately-run/sdk": "^0.72.0",
45
45
  "react": "^19.2.5",
46
46
  "react-dom": "^19.2.5"
47
47
  },
@@ -87,7 +87,7 @@
87
87
  "llms.txt"
88
88
  ],
89
89
  "peerDependencies": {
90
- "@immediately-run/sdk": "^0.67.0",
90
+ "@immediately-run/sdk": "^0.72.0",
91
91
  "react": "^19.2.5",
92
92
  "react-dom": "^19.2.5"
93
93
  }
package/src/GroveApp.css CHANGED
@@ -2654,6 +2654,23 @@ button.grove-search__row {
2654
2654
  .ga-reach__row--neutral .ga-reach__mark {
2655
2655
  color: var(--ink-3);
2656
2656
  }
2657
+ .ga-reach__row--elsewhere .ga-reach__mark {
2658
+ /* A destination, not an approval and not a denial (R3-752): ink, not accent. */
2659
+ color: var(--ink);
2660
+ }
2661
+ /* The row's state as text, for the screen reader the glyph gives nothing (R3-752,
2662
+ R-IX-7): visually hidden, never removed from the tree. */
2663
+ .ga-reach__stateword {
2664
+ position: absolute;
2665
+ width: 1px;
2666
+ height: 1px;
2667
+ padding: 0;
2668
+ margin: -1px;
2669
+ overflow: hidden;
2670
+ clip: rect(0 0 0 0);
2671
+ white-space: nowrap;
2672
+ border: 0;
2673
+ }
2657
2674
  .ga-reach__label {
2658
2675
  color: var(--ink);
2659
2676
  font-weight: 500;
@@ -2669,6 +2686,26 @@ button.grove-search__row {
2669
2686
  .ga-reach__cause {
2670
2687
  color: var(--ink-2);
2671
2688
  }
2689
+ /* R3-790 — the ✗ chat row's earning affordance: a small token'd control inside
2690
+ the reach card (the .ga-chip vocabulary, scaled to the card's 12.5px context). */
2691
+ .ga-reach__enable {
2692
+ font-family: var(--sans);
2693
+ font-weight: 600;
2694
+ font-size: 12px;
2695
+ color: var(--ink);
2696
+ background: var(--panel-2);
2697
+ border: 1px solid var(--line-2);
2698
+ border-radius: var(--r-pill);
2699
+ padding: 3px 10px;
2700
+ margin-left: 8px;
2701
+ cursor: pointer;
2702
+ transition: .14s;
2703
+ }
2704
+ .ga-reach__enable:hover,
2705
+ .ga-reach__enable:focus-visible {
2706
+ border-color: var(--accent-2);
2707
+ color: var(--accent-2);
2708
+ }
2672
2709
  .ga-egress {
2673
2710
  margin: 0;
2674
2711
  padding: 8px 16px;
package/src/GroveWiki.tsx CHANGED
@@ -33,7 +33,7 @@ import { preferredPolarity } from './data/themes';
33
33
  import { useDirectoryListing } from './hooks/useDirectoryListing';
34
34
  import { useScrollReset } from './hooks/useScrollReset';
35
35
  import { useEditAffordance } from './hooks/useEditAffordance';
36
- import { getContentRoot } from './lib/contentRoot';
36
+ import { getContentRoot, isDispatched } from './lib/contentRoot';
37
37
  import type { RejectedComponent } from './lib/corpusComponents';
38
38
  import { GroveShellContext, OutletContext } from './lib/shell';
39
39
  import type { GroveShell, NavItem } from './lib/shell';
@@ -415,10 +415,29 @@ export default function GroveWiki({
415
415
  return (
416
416
  // R3-277b: declare the enclosing bundle for the platform's link-space consumers
417
417
  // (the shared resolver's bundle-anchored absolute + `$fs:` handling read this).
418
- // R3-482: the field keeps the deprecated `corpusRoot` spelling until grove's SDK
419
- // pin reaches a release whose WikiLink reads `bundleRoot` (sdk#171 / 0.68.1) —
420
- // stating only the new spelling now would silently un-anchor every absolute link.
421
- <LinkSpaceContext.Provider value={{ corpusRoot: getContentRoot() }}>
418
+ // R3-482: the SDK pin (^0.72.0, #75) reads `bundleRoot` new-then-old, so the new
419
+ // spelling is stated alone — the deprecated `corpusRoot` stays in the type for
420
+ // older consumers (mdx-plugins' forever-compat) but no longer here.
421
+ <LinkSpaceContext.Provider
422
+ value={{
423
+ bundleRoot: getContentRoot(),
424
+ // R3-184 S2 (PERSISTENCE_SPEC §8.3) — the `$fs:` clamp, on this render path.
425
+ // The clamp that is IN FORCE lives in `lib/content.ts` (`linkSpaceOpts`):
426
+ // `hrefTargetKey` is where a corpus document's link targets actually
427
+ // resolve (grove's own WikiLink override routes there), and it passes
428
+ // `bundleChrooted: isDispatched()` to the shared resolver (R3-319's
429
+ // chroot), so a dispatched corpus document's `$fs:/mnt/{hash}/…` cannot
430
+ // name a federated mount materialised beside it — the app-level mount
431
+ // point is not a corpus path — and the fork's `$fs:` stays
432
+ // mount-absolute. THIS field carries the same flag for the SDK's generic
433
+ // component consumers (WikiLink/MDXComponents): the pinned SDK 0.72.0
434
+ // does not forward it, so it is decided-but-not-consumed until a
435
+ // forwarding release is published and pinned — the booked follow-up,
436
+ // stated here rather than blurred (§8.3's dated note:
437
+ // built-but-not-in-force is a materially different state from done).
438
+ bundleChrooted: isDispatched(),
439
+ }}
440
+ >
422
441
  {/* R3-174: the bundle scope CONTENT reads — sibling to the link space, not a
423
442
  replacement for it. The two answer different questions: `LinkSpaceContext` tells
424
443
  the platform's link resolver where absolute hrefs are anchored; `BundleContext`
@@ -7,7 +7,7 @@
7
7
  // the hooks — the component reads what the host would have said.
8
8
  import { describe, it, expect, vi, beforeAll } from 'vitest';
9
9
  import { act } from 'react';
10
- import { createRoot, type Root } from 'react-dom/client';
10
+ import { createRoot } from 'react-dom/client';
11
11
 
12
12
  // `useHeadings` scans the DOM (no .grove-prose in this harness ⇒ no headings — fine).
13
13
  // `fs` is read only through safeSources on the stuffing path, never in these cases.
@@ -21,77 +21,11 @@ vi.mock('@immediately-run/sdk', async (importOriginal) => ({
21
21
  runAgent: (opts: unknown) => runAgentMock(opts),
22
22
  }));
23
23
 
24
- // An EMITTING stub transport: push channels subscribe per type and tests emit
25
- // host→app messages through it (the same wire the real host speaks).
26
- // The transport contract (`hostTransport.ts`): `onMessage(handler)` receives EVERY
27
- // host→app message; the SDK's addListener filters by `msg.type` itself.
28
- type Handler = (msg: Record<string, unknown>) => void;
29
- const handlers = new Set<Handler>();
30
- const emit = (msg: Record<string, unknown>): void => {
31
- for (const h of handlers) h(msg);
32
- };
33
- // Controllable per-case (the edit-refusal probe rejects through it); the
34
- // default resolves like a permissive host.
35
- const protocolRequest = vi.fn(async () => ({}));
36
- (globalThis as { __immediatelyRun__?: unknown }).__immediatelyRun__ = {
37
- transport: {
38
- sendMessage: vi.fn(),
39
- protocolRequest,
40
- onMessage: (handler: Handler) => {
41
- handlers.add(handler);
42
- return { dispose: () => handlers.delete(handler) };
43
- },
44
- },
45
- };
46
-
47
- import type { GroveShell } from '../lib/shell';
48
- import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
49
- const { default: GroveAgent } = await import('./GroveAgent');
50
- const { GroveShellContext } = await import('../lib/shell');
51
-
52
- const NAV = {
53
- mode: 'github',
54
- namespace: 'immediately-run',
55
- provider: 'github',
56
- repository: 'docs',
57
- ref: 'main',
58
- sandboxPath: '/app',
59
- hash: '',
60
- search: '',
61
- };
62
-
63
- const shell: GroveShell = { openEditor: vi.fn() } as unknown as GroveShell;
64
-
65
- async function renderAgent(props: { writable: boolean }): Promise<{ root: Root; container: HTMLElement }> {
66
- const container = document.createElement('div');
67
- document.body.appendChild(container);
68
- const root = createRoot(container);
69
- await act(async () => {
70
- root.render(
71
- <GroveShellContext.Provider value={shell}>
72
- <TinkerableContext.Provider
73
- value={{ outerHref: '', navigationState: NAV, routingSpec: {} as never, filesMetadata: {} }}
74
- >
75
- <GroveAgent {...props} entryKey="/app/content/wiki/security.mdx" entryTitle="Security" />
76
- </TinkerableContext.Provider>
77
- </GroveShellContext.Provider>,
78
- );
79
- });
80
- return { root, container };
81
- }
82
-
83
- const openPanel = async (container: HTMLElement): Promise<void> => {
84
- await act(async () => {
85
- (container.querySelector('.ga-line input') as HTMLInputElement)?.focus();
86
- });
87
- };
88
-
89
- /** Drive host→app pushes the way the host would: after the app subscribed. */
90
- const push = async (msg: Record<string, unknown>): Promise<void> => {
91
- await act(async () => {
92
- emit(msg);
93
- });
94
- };
24
+ // The transport stub, the renderer and the push driver live in
25
+ // `src/test/groveAgentHarness.tsx` — shared rather than copied, because
26
+ // `GroveAgent.unanswered.test.tsx` needs the same harness in a module registry where the
27
+ // catalog has never been answered (grove#75 round 3).
28
+ const { renderAgent, openPanel, push, protocolRequest } = await import('../../test/groveAgentHarness');
95
29
 
96
30
  // NOTE: no per-test handler reset. The SDK's push channels are module-global and
97
31
  // subscribe to the transport exactly once (the `started` flag in `createPushChannel`),
@@ -101,15 +35,29 @@ const push = async (msg: Record<string, unknown>): Promise<void> => {
101
35
  describe('G-GA-1 — no unbacked capability claims in the DOM', () => {
102
36
  it('writable=false + no provider: no write-flavored chip, no phantom-write copy', async () => {
103
37
  const { container } = await renderAgent({ writable: false });
104
- await push({ type: 'llm-provider', provider: null }); // answered: not-configured
105
- await push({ type: 'api-catalog', methods: [] }); // nothing granted
38
+ // The host generation this models matters, because the two pushes below only make
39
+ // sense together on ONE of them. R-LLM-2 says a frame lacking `llm:chat` is answered
40
+ // `{ provider: null }` — the SAME payload as a granted frame with no key — so on a
41
+ // host that predates R3-688's `ungranted` mark this pair (null provider, empty
42
+ // catalog) is exactly what an ungranted fork sees, and it is indistinguishable at the
43
+ // provider channel from keyless-but-granted. That indistinguishability IS R3-688.
44
+ // A 0.72.0 host would instead mark the answer, which the R3-688 case below covers.
45
+ await push({ type: 'llm-provider', provider: null }); // pre-mark host: null either way
46
+ await push({ type: 'api-catalog', methods: [] }); // nothing granted — the deciding fact
106
47
  await openPanel(container);
107
48
  const text = container.textContent ?? '';
108
49
  expect(text).not.toMatch(/add an entry|fix broken links|reorganize the sidebar|add a timeline/i);
109
50
  expect(text).not.toMatch(/proposes the edit|host confirms the write/i);
110
51
  expect(container.querySelectorAll('.ga-chip').length).toBe(0); // no ✓ rows ⇒ no chips
111
- // And the honest causes ARE there:
112
- expect(text).toContain('no model key connected');
52
+ // And the honest causes ARE there. R3-688 CHANGED which one this case gets, and the
53
+ // change is the point of the item. On a pre-mark host the provider channel cannot
54
+ // tell keyless from ungranted, so the CATALOG is the only fact that discriminates —
55
+ // and it says not granted. The old order asked not-configured first and therefore
56
+ // told a user who may well have a key to go add one, which cannot unblock them.
57
+ // The key cause is still rendered for a frame the catalog says IS granted
58
+ // (`reachCard.test.ts` — "not-configured names the KEY cause").
59
+ expect(text).toContain("wasn't granted chat");
60
+ expect(text).not.toContain('no model key connected');
113
61
  expect(text).toContain('you’re a reader here');
114
62
  });
115
63
  });
@@ -135,6 +83,22 @@ describe('G-GA-2 / R-GA-5 — read-only never blocks Q&A', () => {
135
83
  expect(container.textContent).not.toMatch(/Suggest an edit/i); // no write chip while read-only
136
84
  });
137
85
 
86
+ it('keyless but GRANTED renders the KEY cause — the case the catalog decides the other way', async () => {
87
+ // The counterpart the suite lost when the G-GA-1 case above changed verdict, and the
88
+ // one that proves the reorder did not simply make the grant cause win everywhere.
89
+ // Same provider payload as that case — `{ provider: null }`, which R-LLM-2 gives both
90
+ // a keyless frame and (on a pre-mark host) an ungranted one — but the catalog says
91
+ // `llm:chat` IS granted. So the only actionable fact is the missing key, and that is
92
+ // what the card must say.
93
+ const { container } = await renderAgent({ writable: true });
94
+ await push({ type: 'llm-provider', provider: null });
95
+ await push({ type: 'api-catalog', methods: [{ name: 'llm:chat', capability: 'llm:chat', stream: true }] });
96
+ await openPanel(container);
97
+ const text = container.textContent ?? '';
98
+ expect(text).toContain('no model key connected — add one in Settings');
99
+ expect(text).not.toContain("wasn't granted chat");
100
+ });
101
+
138
102
  it('an ungranted fork renders the DISTINCT forbidden cause, never connect-a-key copy', async () => {
139
103
  const { container } = await renderAgent({ writable: true });
140
104
  await push({
@@ -154,6 +118,19 @@ describe('G-GA-2 / R-GA-5 — read-only never blocks Q&A', () => {
154
118
  expect(input.disabled).toBe(true); // cannot ask — but reading works, and the card says why
155
119
  });
156
120
 
121
+ it('R3-688 — the host-marked grantless answer renders the consent cause without any provider', async () => {
122
+ // The ungranted fork is answered {provider:null, ungranted:true} — never the
123
+ // provider — so before the mark this fork could only render the KEY copy. The
124
+ // mark is the host's grant decision; the card repeats it, never invents it.
125
+ const { container } = await renderAgent({ writable: true });
126
+ await push({ type: 'llm-provider', provider: null, ungranted: true });
127
+ await push({ type: 'api-catalog', methods: [] });
128
+ await openPanel(container);
129
+ const text = container.textContent ?? '';
130
+ expect(text).toContain("this Grove wasn't granted chat — reading works as normal");
131
+ expect(text).not.toContain('add one in Settings'); // the KEY copy — never conflated (G-GA-10)
132
+ });
133
+
157
134
  it('R-GA-6 — the egress disclosure shows whenever a provider is bound', async () => {
158
135
  const { container } = await renderAgent({ writable: false });
159
136
  await push({
@@ -168,6 +145,79 @@ describe('G-GA-2 / R-GA-5 — read-only never blocks Q&A', () => {
168
145
  await openPanel(container);
169
146
  expect(container.textContent).toContain('answers come from your connected model provider');
170
147
  });
148
+
149
+ // R3-752 — the apply row is a DESTINATION: `→` on the row, no ✗ anywhere on the
150
+ // card, and every row's state is text as well as glyph.
151
+ it('R3-752 — read-only + configured granted provider: the apply row renders → elsewhere, never ✗, with hidden state words', async () => {
152
+ const { container } = await renderAgent({ writable: false });
153
+ await push({
154
+ type: 'llm-provider',
155
+ provider: {
156
+ providerId: 'llm.chat.anthropic',
157
+ hostVouched: true,
158
+ features: { vision: false, tools: true, jsonMode: true, reasoning: false, maxContextTokens: 100000 },
159
+ },
160
+ });
161
+ await push({ type: 'api-catalog', methods: [{ name: 'llm:chat', capability: 'llm:chat', stream: true }] });
162
+ await openPanel(container);
163
+ const card = container.querySelector('.ga-reach')!;
164
+ const applyRow = card.querySelector('.ga-reach__row--elsewhere')!;
165
+ expect(applyRow.textContent).toContain('Apply changes');
166
+ expect(applyRow.textContent).toContain('→ in the editor or workbench, where you confirm them');
167
+ // A destination is never rendered as a denial: the card's only ✗ is the draft
168
+ // row's (read-only), the apply row's mark is the arrow.
169
+ expect(applyRow.querySelector('.ga-reach__mark')!.textContent).toBe('→');
170
+ expect(card.textContent).not.toContain('changes open in the editor'); // the old cause copy is gone
171
+ // State is text, never glyph alone: the hidden words are in the tree.
172
+ const words = [...card.querySelectorAll('.ga-reach__stateword')].map((n) => n.textContent);
173
+ expect(words).toContain('available');
174
+ expect(words).toContain('unavailable');
175
+ expect(words).toContain('opens elsewhere');
176
+ // The substrate is context, stated, with the git-indeterminate trust line under
177
+ // the card (the SDK's fail-closed default basis).
178
+ expect(card.textContent).toContain('Reads its own entries');
179
+ expect(card.parentElement!.textContent).toContain('anyone who can push to this repo can change what the agent reads');
180
+ });
181
+
182
+ it('R3-752 — a grant flip is ANNOUNCED, not only re-rendered (R-IX-7): the standing live region tracks the card', async () => {
183
+ const { container } = await renderAgent({ writable: false });
184
+ await push({
185
+ type: 'llm-provider',
186
+ provider: {
187
+ providerId: 'llm.chat.anthropic',
188
+ hostVouched: true,
189
+ features: { vision: false, tools: true, jsonMode: true, reasoning: false, maxContextTokens: 100000 },
190
+ },
191
+ });
192
+ await push({ type: 'api-catalog', methods: [] }); // key, but NOT granted chat
193
+ await openPanel(container);
194
+ const live = container.querySelector('[role="status"].ga-reach__stateword')!;
195
+ expect(live.getAttribute('aria-live') ?? 'polite').toBe('polite'); // role=status is polite by default
196
+ expect(live.textContent).toContain('unavailable'); // the blocked Q&A row, in words
197
+
198
+ // Grant chat: the flip must change the announcement — the Q&A segment goes
199
+ // unavailable → available (the draft row stays honestly unavailable; read-only).
200
+ await push({ type: 'api-catalog', methods: [{ name: 'llm:chat', capability: 'llm:chat', stream: true }] });
201
+ expect(live.textContent).toContain('Answer questions about this wiki — available');
202
+ expect(live.textContent).not.toContain('Answer questions about this wiki — unavailable');
203
+ });
204
+
205
+ it('R3-752 — a configured provider WITHOUT tools keeps Q&A ✓ and shows the degrade qualifier, not silence', async () => {
206
+ const { container } = await renderAgent({ writable: false });
207
+ await push({
208
+ type: 'llm-provider',
209
+ provider: {
210
+ providerId: 'llm.chat.anthropic',
211
+ hostVouched: true,
212
+ features: { vision: false, tools: false, jsonMode: true, reasoning: false, maxContextTokens: 100000 },
213
+ },
214
+ });
215
+ await push({ type: 'api-catalog', methods: [{ name: 'llm:chat', capability: 'llm:chat', stream: true }] });
216
+ await openPanel(container);
217
+ const answerRow = container.querySelector('.ga-reach__row--ok')!;
218
+ expect(answerRow.textContent).toContain('Answer questions about this wiki');
219
+ expect(answerRow.textContent).toContain('reads a summary of this wiki, not entries on demand');
220
+ });
171
221
  });
172
222
 
173
223
  describe('R3-608 — the composer stops the run; a refusal surfaces, a cancel does not', () => {
@@ -278,21 +328,10 @@ describe('R3-608 — the composer stops the run; a refusal surfaces, a cancel do
278
328
  });
279
329
 
280
330
  it('the agent panel renders the refusal text where the affordance is offered', async () => {
281
- const host = document.createElement('div');
282
- document.body.appendChild(host);
283
- const root = createRoot(host);
284
- const refusingShell: GroveShell = { openEditor: vi.fn(), editRefused: true } as unknown as GroveShell;
285
- await act(async () => {
286
- root.render(
287
- <GroveShellContext.Provider value={refusingShell}>
288
- <TinkerableContext.Provider
289
- value={{ outerHref: '', navigationState: NAV, routingSpec: {} as never, filesMetadata: {} }}
290
- >
291
- <GroveAgent writable={false} entryKey="/app/content/wiki/security.mdx" entryTitle="Security" />
292
- </TinkerableContext.Provider>
293
- </GroveShellContext.Provider>,
294
- );
295
- });
331
+ const { root, container: host } = await renderAgent(
332
+ { writable: false },
333
+ { shell: { editRefused: true } as Partial<import('../lib/shell').GroveShell> },
334
+ );
296
335
  await openPanel(host);
297
336
  expect(host.textContent).toContain('Could not open the editor — the host refused');
298
337
  await act(async () => {
@@ -328,3 +367,155 @@ describe('R3-608 round 2 — the panel returns focus to its (unmounted) trigger'
328
367
  expect(container.querySelector('.ga-panel')).toBeNull(); // focus did not reopen
329
368
  });
330
369
  });
370
+
371
+ // ── R3-790 — the ungranted chat row's earning affordance ──────────────────────
372
+
373
+ describe("R3-790 — the ✗ chat row offers its earning affordance (click invokes; the host gesture mints)", () => {
374
+ beforeAll(() => {
375
+ HTMLElement.prototype.scrollTo = () => undefined as unknown as void;
376
+ });
377
+
378
+ it('renders Enable chat on the ungranted row — the host-marked forbidden cause', async () => {
379
+ const { container } = await renderAgent({ writable: false });
380
+ // The cold mark — no prior configured push: the host strips the provider from
381
+ // every answer to an ungranted frame (the R3-688 test's shape).
382
+ await push({ type: 'llm-provider', provider: null, ungranted: true });
383
+ await push({ type: 'api-catalog', methods: [] }); // answered: nothing granted
384
+ await openPanel(container);
385
+ const enable = container.querySelector('.ga-reach__enable');
386
+ expect(enable).toBeTruthy(); // the affordance exists on the ✗ row
387
+ expect((enable as HTMLElement).textContent).toBe('Enable chat');
388
+ // …and the row itself still tells the honest cause.
389
+ expect(container.querySelector('.ga-reach__row--blocked')?.textContent).toContain(
390
+ "this Grove wasn't granted chat",
391
+ );
392
+ });
393
+
394
+ it('renders NO affordance when the provider is not configured (that row points at Settings)', async () => {
395
+ const { container } = await renderAgent({ writable: false });
396
+ await push({ type: 'llm-provider', provider: null }); // not-configured, not ungranted
397
+ await push({ type: 'api-catalog', methods: [] });
398
+ await openPanel(container);
399
+ expect(container.querySelector('.ga-reach__enable')).toBeNull();
400
+ });
401
+
402
+ it("a click invokes; a DECLINED dialog ('cancelled') keeps the honest row and says so", async () => {
403
+ runAgentMock.mockClear();
404
+ runAgentMock.mockImplementationOnce(() => {
405
+ const e = new Error('declined') as Error & { code?: string };
406
+ e.code = 'cancelled';
407
+ throw e;
408
+ });
409
+ const { container } = await renderAgent({ writable: false });
410
+ // The cold mark — no prior configured push: the host strips the provider from
411
+ // every answer to an ungranted frame (the R3-688 test's shape).
412
+ await push({ type: 'llm-provider', provider: null, ungranted: true });
413
+ await push({ type: 'api-catalog', methods: [] });
414
+ await openPanel(container);
415
+ await act(async () => {
416
+ (container.querySelector('.ga-reach__enable') as HTMLButtonElement).click();
417
+ });
418
+ // The invoke RAN (runAgent was called once — the gesture's trigger)…
419
+ expect(runAgentMock).toHaveBeenCalledTimes(1);
420
+ // …the decline surfaced honestly…
421
+ await act(async () => {
422
+ await new Promise((r) => setTimeout(r, 16));
423
+ });
424
+ expect(container.querySelector('.ga-toast')?.textContent).toContain("chat didn't start — reading works as normal; you can try again anytime");
425
+ // …and the row still reads the forbidden cause with the affordance for the next click.
426
+ expect(container.querySelector('.ga-reach__row--blocked')?.textContent).toContain(
427
+ "this Grove wasn't granted chat",
428
+ );
429
+ expect(container.querySelector('.ga-reach__enable')).toBeTruthy();
430
+ });
431
+
432
+ it('a click invokes; a still-FORBIDDEN answer surfaces the refusal toast (pre-gesture hosts)', async () => {
433
+ runAgentMock.mockImplementationOnce(() => {
434
+ const e = new Error('forbidden') as Error & { code?: string };
435
+ e.code = 'forbidden';
436
+ throw e;
437
+ });
438
+ const { container } = await renderAgent({ writable: false });
439
+ // The cold mark — no prior configured push: the host strips the provider from
440
+ // every answer to an ungranted frame (the R3-688 test's shape).
441
+ await push({ type: 'llm-provider', provider: null, ungranted: true });
442
+ await push({ type: 'api-catalog', methods: [] });
443
+ await openPanel(container);
444
+ await act(async () => {
445
+ (container.querySelector('.ga-reach__enable') as HTMLButtonElement).click();
446
+ });
447
+ await act(async () => {
448
+ await new Promise((r) => setTimeout(r, 16));
449
+ });
450
+ expect(container.querySelector('.ga-toast')?.textContent).toContain("this Grove wasn't granted chat");
451
+ });
452
+ });
453
+
454
+ // ── R3-790 round 1 — the GRANTED leg: the click's invoke proceeds when the grant lands ──
455
+
456
+ describe("R3-790 — the granted leg (click → invoke → the grant lands → the ask proceeds, the row flips)", () => {
457
+ beforeAll(() => {
458
+ HTMLElement.prototype.scrollTo = () => undefined as unknown as void;
459
+ });
460
+ const configured = {
461
+ type: 'llm-provider',
462
+ provider: {
463
+ providerId: 'llm.chat.anthropic',
464
+ hostVouched: true,
465
+ features: { vision: false, tools: true, jsonMode: true, reasoning: false, maxContextTokens: 100000 },
466
+ },
467
+ } as const;
468
+ const grantedCatalog = {
469
+ type: 'api-catalog',
470
+ methods: [{ name: 'llm:chat', capability: 'llm:chat', stream: true }],
471
+ } as const;
472
+
473
+ it("the affordance's click drives the invoke; the granted catalog flips the row ✓ and the input enables", async () => {
474
+ runAgentMock.mockClear();
475
+ runAgentMock.mockImplementationOnce(async () => [
476
+ {
477
+ role: 'user',
478
+ content: [{ type: 'text', text: 'Answer in one sentence: what can you tell me about this wiki?' }],
479
+ },
480
+ {
481
+ role: 'assistant',
482
+ content: [{ type: 'text', text: 'This wiki is a small corpus about enabling chat.' }],
483
+ },
484
+ ]);
485
+ const { container } = await renderAgent({ writable: false });
486
+ await push({ type: 'llm-provider', provider: null, ungranted: true });
487
+ await push({ type: 'api-catalog', methods: [] });
488
+ await openPanel(container);
489
+ // Before: the honest ✗ row + the affordance.
490
+ expect(container.querySelector('.ga-reach__row--blocked')?.textContent).toContain(
491
+ "this Grove wasn't granted chat",
492
+ );
493
+ await act(async () => {
494
+ (container.querySelector('.ga-reach__enable') as HTMLButtonElement).click();
495
+ });
496
+ // The invoke RAN (the gesture's trigger).
497
+ expect(runAgentMock).toHaveBeenCalledTimes(1);
498
+ // The grant lands (the host's mint + lift answers the channels): push the
499
+ // granted catalog + the configured provider — the envelope recomputes.
500
+ await push(grantedCatalog);
501
+ await push(configured);
502
+ await act(async () => {
503
+ await new Promise((r) => setTimeout(r, 16));
504
+ });
505
+ await act(async () => {
506
+ await new Promise((r) => setTimeout(r, 16));
507
+ });
508
+ // The ask PROCEEDED: the assistant answer is in the body…
509
+ expect(container.querySelector('.ga-body')?.textContent).toContain('enabling chat');
510
+ // …the ANSWER row flipped ✓ (the read-only draft row stays honestly ✗ — that is
511
+ // its own row, not this one)…
512
+ const answerRow = [...container.querySelectorAll('.ga-reach__row')].find((r) =>
513
+ r.textContent?.includes('Answer questions about this wiki'),
514
+ );
515
+ expect(answerRow?.className).not.toContain('blocked');
516
+ expect(container.querySelector('.ga-reach__enable')).toBeNull();
517
+ // …and the composer input is ENABLED (canAsk true).
518
+ const input = container.querySelector('.ga-foot input') as HTMLInputElement;
519
+ expect(input?.disabled).toBe(false);
520
+ });
521
+ });