@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 +1 -1
- package/package.json +3 -3
- package/src/GroveApp.css +37 -0
- package/src/GroveWiki.tsx +24 -5
- package/src/components/GroveAgent.test.tsx +282 -91
- package/src/components/GroveAgent.tsx +73 -13
- package/src/components/GroveAgent.unanswered.test.tsx +45 -0
- package/src/components/PageView.tsx +6 -0
- package/src/hooks/useCatalogAnswered.late.test.tsx +59 -0
- package/src/hooks/useCatalogAnswered.test.tsx +89 -0
- package/src/hooks/useCatalogAnswered.ts +73 -0
- package/src/hooks/useEditAffordance.ts +6 -9
- package/src/hooks/useHeadingFragmentUrl.test.tsx +146 -0
- package/src/hooks/useHeadingFragmentUrl.ts +98 -0
- package/src/lib/content.test.ts +47 -1
- package/src/lib/content.ts +21 -2
- package/src/lib/reachCard.test.ts +507 -61
- package/src/lib/reachCard.ts +193 -28
- package/src/lib/shell.ts +3 -2
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
<LinkSpaceContext.Provider
|
|
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
|
|
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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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 =
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
+
});
|