@immediately-run/grove 0.1.5 → 0.1.7
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 +17 -0
- package/src/GroveWiki.tsx +24 -5
- package/src/components/GroveAgent.test.tsx +130 -91
- package/src/components/GroveAgent.tsx +53 -11
- package/src/components/GroveAgent.unanswered.test.tsx +45 -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/lib/content.test.ts +47 -1
- package/src/lib/content.ts +21 -2
- package/src/lib/reachCard.test.ts +479 -61
- package/src/lib/reachCard.ts +170 -28
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.7)
|
|
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.7",
|
|
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;
|
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 () => {
|
|
@@ -15,18 +15,22 @@ import { useOverlayFocusDismiss } from '../hooks/useOverlayFocusDismiss';
|
|
|
15
15
|
import { getContentRoot } from '../lib/contentRoot';
|
|
16
16
|
import { createReadEntryTool, createGroveMetadataTool, groveAgentTools, toolExecutor } from '../lib/agentTools';
|
|
17
17
|
import { buildSystemPrompt } from '../lib/agentPrompt';
|
|
18
|
-
import { computeReachRows, reachChips, showEgressDisclosure, EGRESS_DISCLOSURE } from '../lib/reachCard';
|
|
18
|
+
import { computeReachRows, reachChips, sourceTrustLine, stateWord, showEgressDisclosure, EGRESS_DISCLOSURE } from '../lib/reachCard';
|
|
19
|
+
import { getCorpusMountId } from '../lib/contentRoot';
|
|
19
20
|
import { transcriptToRows, toolActivityLine, type AgentRow } from '../lib/agentTranscript';
|
|
21
|
+
import { useCatalogAnswered } from '../hooks/useCatalogAnswered';
|
|
20
22
|
import { safeSources } from '../lib/safeSources';
|
|
21
23
|
import Icon from './Icon';
|
|
22
24
|
|
|
23
25
|
// `.grove-agent` — Grove's own embedded agent (GROVE_AGENT_SPEC).
|
|
24
26
|
//
|
|
25
27
|
// The surface is a FUNCTION of the session's envelope (R-GA-1): the reach card in
|
|
26
|
-
// the expanded header is computed from the provider
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
28
|
+
// the expanded header is computed from the provider four-state (R3-688 added the
|
|
29
|
+
// host-marked `ungranted` — the distinct not-granted cause), the `llm:chat`
|
|
30
|
+
// grant (the grant-filtered catalog), mount writability, the corpus packaging,
|
|
31
|
+
// tools support, and source trust — never hand-written copy. The loop rides the
|
|
32
|
+
// workbench's seam — SDK `runAgent` over the host `llm.chat` slot — and its two
|
|
33
|
+
// tools are the mount-chrooted `read_entry` and
|
|
30
34
|
// the index query (R-GA-2). The widget never writes (R-GA-3): every change is a
|
|
31
35
|
// hand-off to the editor / workbench. Read-only never blocks Q&A (R-GA-5). When a
|
|
32
36
|
// provider is bound the egress line is shown unconditionally (R-GA-6). Every
|
|
@@ -66,12 +70,40 @@ export default function GroveAgent({
|
|
|
66
70
|
// catalog iff this app holds the consent — an ungranted fork reads a DISTINCT
|
|
67
71
|
// cause from a user without a key (G-GA-10).
|
|
68
72
|
const chatGranted = catalog.some((m) => m.name === 'llm:chat');
|
|
73
|
+
// Distinguishes "granted nothing" from "has not replied yet" — `catalog` alone cannot,
|
|
74
|
+
// because an empty catalog is a legitimate answer. See the hook for how it is derived.
|
|
75
|
+
const catalogAnswered = useCatalogAnswered();
|
|
69
76
|
const context = useAgentContext({ entryPath: entryKey, entryTitle, heading: activeHeading || undefined });
|
|
77
|
+
// G-GA-8: a provider without `features.tools` degrades to context-stuffing.
|
|
78
|
+
// Computed ONCE at render scope — the reach card's Q&A qualifier (R3-752) and the
|
|
79
|
+
// ask() path below read the same fact, never two computations of it.
|
|
80
|
+
const toolsSupported = providerState.status === 'configured' && providerState.provider.features.tools === true;
|
|
70
81
|
const reachRows = useMemo(
|
|
71
|
-
() =>
|
|
72
|
-
|
|
82
|
+
() =>
|
|
83
|
+
computeReachRows({
|
|
84
|
+
providerState,
|
|
85
|
+
chatGranted,
|
|
86
|
+
catalogAnswered,
|
|
87
|
+
writable,
|
|
88
|
+
sourceShared: context.sourceShared,
|
|
89
|
+
mountId: getCorpusMountId(),
|
|
90
|
+
toolsSupported,
|
|
91
|
+
}),
|
|
92
|
+
[providerState, chatGranted, catalogAnswered, writable, context.sourceShared, toolsSupported],
|
|
73
93
|
);
|
|
74
94
|
const chips = useMemo(() => reachChips(reachRows), [reachRows]);
|
|
95
|
+
const trustLine = useMemo(
|
|
96
|
+
() => sourceTrustLine(context.sourceShared, context.sourceSharedBasis),
|
|
97
|
+
[context.sourceShared, context.sourceSharedBasis],
|
|
98
|
+
);
|
|
99
|
+
// R-IX-7 (R3-752): a state flip is ANNOUNCED, not only re-rendered — one standing
|
|
100
|
+
// polite live region whose text is the card's computed summary. A grant flip
|
|
101
|
+
// changes the text, which is what a status region announces; the visually hidden
|
|
102
|
+
// per-row words are static text and announce nothing.
|
|
103
|
+
const reachAnnouncement = useMemo(
|
|
104
|
+
() => `What the agent can do here: ${reachRows.map((r) => `${r.label} — ${stateWord(r.state)}`).join('; ')}`,
|
|
105
|
+
[reachRows],
|
|
106
|
+
);
|
|
75
107
|
const canAsk = providerState.status === 'configured' && chatGranted;
|
|
76
108
|
|
|
77
109
|
// Read at CALL time (a scan may land, the reader may navigate) — refs kept fresh
|
|
@@ -121,8 +153,8 @@ export default function GroveAgent({
|
|
|
121
153
|
|
|
122
154
|
// G-GA-8: a provider without `features.tools` degrades to context-stuffing —
|
|
123
155
|
// the deixis block, an index summary, and the current entry body ride the
|
|
124
|
-
// prompt; the request carries ZERO tools.
|
|
125
|
-
|
|
156
|
+
// prompt; the request carries ZERO tools. The same hoisted fact the reach
|
|
157
|
+
// card's Q&A qualifier reads (R3-752) — computed once at render scope.
|
|
126
158
|
const chroot = getContentRoot();
|
|
127
159
|
const currentKey = entryRef.current;
|
|
128
160
|
const contextBlock = renderAgentContext({
|
|
@@ -271,18 +303,28 @@ export default function GroveAgent({
|
|
|
271
303
|
</span>
|
|
272
304
|
</div>
|
|
273
305
|
|
|
274
|
-
{/* The reach card — the envelope, computed (R-GA-1).
|
|
306
|
+
{/* The reach card — the envelope, computed (R-GA-1). State is text as
|
|
307
|
+
well as glyph (R3-752): the hidden word beside the mark keeps the
|
|
308
|
+
card from reading as four identical lines to a screen reader. */}
|
|
275
309
|
<div className="ga-reach" role="list" aria-label="What the agent can do here">
|
|
276
310
|
{reachRows.map((r) => (
|
|
277
311
|
<div className={`ga-reach__row ga-reach__row--${r.state}`} role="listitem" key={r.key}>
|
|
278
312
|
<span className="ga-reach__mark" aria-hidden>
|
|
279
|
-
{r.state === 'ok' ? '✓' : r.state === 'blocked' ? '✗' : '·'}
|
|
313
|
+
{r.state === 'ok' ? '✓' : r.state === 'blocked' ? '✗' : r.state === 'elsewhere' ? '→' : '·'}
|
|
280
314
|
</span>
|
|
315
|
+
<span className="ga-reach__stateword">{stateWord(r.state)}</span>
|
|
281
316
|
<span className="ga-reach__label">{r.label}</span>
|
|
282
317
|
{r.cause && <span className="ga-reach__cause">{r.cause}</span>}
|
|
318
|
+
{r.destination && <span className="ga-reach__cause">{`→ ${r.destination}`}</span>}
|
|
283
319
|
</div>
|
|
284
320
|
))}
|
|
285
321
|
</div>
|
|
322
|
+
{trustLine && <p className="ga-egress">{trustLine}</p>}
|
|
323
|
+
{/* The standing live region (R-IX-7): the computed card summary, so a
|
|
324
|
+
state flip is announced politely and not only re-rendered. */}
|
|
325
|
+
<div className="ga-reach__stateword" role="status">
|
|
326
|
+
{reachAnnouncement}
|
|
327
|
+
</div>
|
|
286
328
|
{showEgressDisclosure(providerState) && <p className="ga-egress">{EGRESS_DISCLOSURE}</p>}
|
|
287
329
|
{errorToast && (
|
|
288
330
|
<div className="ga-toast" role="status">
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// ONE case, in its own file, because it needs a module registry where the catalog has
|
|
3
|
+
// never been answered.
|
|
4
|
+
//
|
|
5
|
+
// `useCatalogAnswered`'s flag is module-global and never resets — correct for an app, in
|
|
6
|
+
// which the host answers once per realm, but it means any test that has already pushed an
|
|
7
|
+
// `api-catalog` poisons this one. Every case in `GroveAgent.test.tsx` pushes one. Vitest
|
|
8
|
+
// isolates by file, so a separate file IS the isolation; the harness is shared rather
|
|
9
|
+
// than copied (`src/test/groveAgentHarness.tsx`).
|
|
10
|
+
//
|
|
11
|
+
// Why this case exists at all: round 3 showed the hook was not load-bearing for any
|
|
12
|
+
// component test — replacing `useCatalogAnswered()` with the literal `true`, i.e. exactly
|
|
13
|
+
// the behaviour the hook was written to replace, left all 506 tests green. The unit arm
|
|
14
|
+
// (`reachCard.test.ts`) and the hook itself were covered; the wire between them was not.
|
|
15
|
+
// That is round 1's blocking finding recurring one level up.
|
|
16
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
17
|
+
|
|
18
|
+
// `useHeadings` scans the DOM; `fs` is read only on the stuffing path, never here.
|
|
19
|
+
vi.mock('fs', () => ({ default: { promises: { readFile: vi.fn(async () => '') } } }));
|
|
20
|
+
vi.mock('@immediately-run/sdk', async (importOriginal) => ({
|
|
21
|
+
...(await importOriginal<typeof import('@immediately-run/sdk')>()),
|
|
22
|
+
runAgent: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const { renderAgent, openPanel, push } = await import('../../test/groveAgentHarness');
|
|
26
|
+
|
|
27
|
+
describe('R3-688 — before the catalog answers, the Q&A row names no cause', () => {
|
|
28
|
+
it('renders neutral: not a guessed consent state, not a guessed key state, no chips', async () => {
|
|
29
|
+
// No catalog push at all. The provider HAS answered `{ provider: null }`, which on a
|
|
30
|
+
// pre-mark host is what both a keyless frame and an ungranted fork receive — so the
|
|
31
|
+
// grant is genuinely unknown and the card must say nothing rather than pick one.
|
|
32
|
+
const { container } = await renderAgent({ writable: true });
|
|
33
|
+
await push({ type: 'llm-provider', provider: null });
|
|
34
|
+
await openPanel(container);
|
|
35
|
+
const text = container.textContent ?? '';
|
|
36
|
+
|
|
37
|
+
expect(text).toContain('Answer questions about this wiki');
|
|
38
|
+
expect(text).not.toContain("wasn't granted chat");
|
|
39
|
+
expect(text).not.toContain('no model key connected');
|
|
40
|
+
// No Q&A chips. Not "no chips at all": `writable: true` makes the Draft row ✓ and it
|
|
41
|
+
// contributes its own, which is correct and has nothing to do with the catalog.
|
|
42
|
+
const chips = [...container.querySelectorAll('.ga-chip')].map((c) => c.textContent ?? '');
|
|
43
|
+
expect(chips.some((c) => /summarize|tagged security/i.test(c))).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// The LATE-IMPORT case, in its own file because it is the one ordering the sibling suite
|
|
3
|
+
// cannot produce: the host answers BEFORE this module is evaluated.
|
|
4
|
+
//
|
|
5
|
+
// grove is published as a pinned library (`src/lib.ts`), so "imported before any render"
|
|
6
|
+
// is an assumption about one embedding, not a guarantee. If the subscribe-time call is
|
|
7
|
+
// discarded with a blanket reset, a host that already answered is invisible for the
|
|
8
|
+
// realm's life — and because the host does not push again, nothing corrects it.
|
|
9
|
+
//
|
|
10
|
+
// This file exists because round 3's own fix was untested: replacing
|
|
11
|
+
// `answered = catalog.length > 0` with `answered = false` — i.e. the blanket reset the
|
|
12
|
+
// fix replaced — left the whole suite green. A fix with no failing case is a guess.
|
|
13
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
14
|
+
|
|
15
|
+
type Handler = (msg: Record<string, unknown>) => void;
|
|
16
|
+
const handlers = new Set<Handler>();
|
|
17
|
+
const emit = (msg: Record<string, unknown>): void => {
|
|
18
|
+
for (const h of handlers) h(msg);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
(globalThis as { __immediatelyRun__?: unknown }).__immediatelyRun__ = {
|
|
22
|
+
transport: {
|
|
23
|
+
sendMessage: vi.fn(),
|
|
24
|
+
protocolRequest: vi.fn(async () => ({})),
|
|
25
|
+
onMessage: (handler: Handler) => {
|
|
26
|
+
handlers.add(handler);
|
|
27
|
+
return { dispose: () => handlers.delete(handler) };
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe('useCatalogAnswered, imported AFTER the host answered', () => {
|
|
33
|
+
it('reads a non-empty catalog at subscribe as proof the answer landed', async () => {
|
|
34
|
+
// Start and answer the channel through the SDK directly, with the hook module not yet
|
|
35
|
+
// imported — the ordering a late-mounting library consumer produces.
|
|
36
|
+
const { getCatalog } = await import('@immediately-run/sdk');
|
|
37
|
+
getCatalog();
|
|
38
|
+
emit({ type: 'api-catalog', methods: [{ name: 'llm:chat', capability: 'llm:chat', stream: true }] });
|
|
39
|
+
|
|
40
|
+
// NOW import. The listener's synchronous subscribe-time call carries the answered
|
|
41
|
+
// catalog; a blanket reset would throw it away and the flag would be false forever.
|
|
42
|
+
const { useCatalogAnswered } = await import('./useCatalogAnswered');
|
|
43
|
+
|
|
44
|
+
const { act } = await import('react');
|
|
45
|
+
const { createRoot } = await import('react-dom/client');
|
|
46
|
+
const seen: boolean[] = [];
|
|
47
|
+
const Probe = () => {
|
|
48
|
+
seen.push(useCatalogAnswered());
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
const root = createRoot(document.createElement('div'));
|
|
52
|
+
await act(async () => {
|
|
53
|
+
root.render(<Probe />);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(seen[seen.length - 1]).toBe(true);
|
|
57
|
+
await act(async () => root.unmount());
|
|
58
|
+
});
|
|
59
|
+
});
|