Skip to content

Commit 6028192

Browse files
os-zhuangclaude
andauthored
fix(console): gate AI surface on the access-filtered agent catalog, not discovery (per-user AI seat) (#1993)
Reverts objectui#1992. `useAiSurfaceEnabled` keys off `GET /api/v1/ai/agents` again (>= 1 agent → AI shows). That route is now access-filtered server-side (framework ADR-0049 / ADR-0068): it returns only the agents the CALLER may chat, so a user WITHOUT the per-user AI seat (`ai_seat`) gets an empty catalog and the ENTIRE AI surface — FAB, `/ai` routes, top-bar + designer "Ask AI" — hides for them, instead of showing a control that 403s on click. #1992 had moved the gate to the deployment-wide discovery `services.ai` capability, which is identical for every user and so cannot express per-user seating — the wrong signal for the AI-seat gate. Community-Edition gating is unaffected (no service-ai → no agents → empty catalog → hidden). Updated the hook docs with a "do NOT flip back to discovery" note so the per-user dimension isn't dropped again. app-shell builds clean; useAiSurface tests (4) green. The catalog's per-user filtering is proven server-side (framework branch feat/ai-seat-catalog-filter): seated user → ['build','ask'], seat-less → []. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a3a5abf commit 6028192

4 files changed

Lines changed: 96 additions & 74 deletions

File tree

.changeset/ai-gate-on-service-ai.md

Lines changed: 0 additions & 23 deletions
This file was deleted.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(console): gate the AI surface on the access-filtered agent catalog (per-user), not the deployment-wide service-ai capability
6+
7+
`useAiSurfaceEnabled` keys off `GET /api/v1/ai/agents` again (>= 1 agent → AI shows), reverting objectui#1992. The agent-catalog route is now access-filtered server-side (ADR-0049 / ADR-0068): it returns only the agents the caller may chat, so a user WITHOUT the per-user AI seat (`ai_seat`) gets an empty catalog and the whole AI surface (FAB, `/ai` routes, top-bar + designer "Ask AI") hides for them — instead of showing a control that 403s on click. The discovery `services.ai` flag is deployment-wide and cannot express per-user seating, so it is the wrong signal for the AI-seat gate. Community-Edition gating is unaffected: no service-ai → no agents → empty catalog → hidden.
Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,55 @@
11
import { describe, it, expect, vi, afterEach } from 'vitest';
22
import { renderHook } from '@testing-library/react';
3-
import { useDiscovery } from '@object-ui/react';
3+
import { useAgents } from '@object-ui/plugin-chatbot';
44
import { useAiSurfaceEnabled } from '../useAiSurface';
55

6-
// The AI surface gates on whether the `service-ai` capability is present, as
7-
// reported by discovery. Mock useDiscovery rather than standing up a data source.
8-
// (The VITE_AI_BASE_URL opt-in branch isn't unit-tested here: vitest fixes
9-
// import.meta.env at startup, so it can't be stubbed per-test; it's a thin
10-
// synchronous env read exercised in the real app.)
11-
vi.mock('@object-ui/react', () => ({ useDiscovery: vi.fn() }));
12-
const mockDiscovery = vi.mocked(useDiscovery);
6+
// The surface is gated on the live agent catalog, so we mock useAgents and
7+
// assert the catalog → enabled/loading mapping (incl. the not-yet-fetched latch).
8+
vi.mock('@object-ui/plugin-chatbot', () => ({ useAgents: vi.fn() }));
9+
const mockAgents = vi.mocked(useAgents);
1310

14-
afterEach(() => mockDiscovery.mockReset());
11+
function agentsResult(names: string[], isLoading: boolean) {
12+
return {
13+
agents: names.map((name) => ({ name, label: name })),
14+
isLoading,
15+
error: undefined,
16+
refetch: vi.fn(),
17+
};
18+
}
19+
20+
afterEach(() => mockAgents.mockReset());
1521

1622
describe('useAiSurfaceEnabled', () => {
17-
it('is enabled when discovery reports service-ai available (enterprise install)', () => {
18-
mockDiscovery.mockReturnValue({ isAiEnabled: true, isLoading: false } as any);
23+
it('is enabled when the catalog serves at least one agent (cloud / agents present)', () => {
24+
mockAgents.mockReturnValue(agentsResult(['ask'], false));
1925
const { result } = renderHook(() => useAiSurfaceEnabled());
2026
expect(result.current).toEqual({ enabled: true, isLoading: false });
2127
});
2228

23-
it('is disabled — not loading — when service-ai is unavailable (Community Edition: no service-ai)', () => {
24-
mockDiscovery.mockReturnValue({ isAiEnabled: false, isLoading: false } as any);
29+
it('reports loading on the first frame before the catalog fetch has started', () => {
30+
// useAgents starts isLoading=false with an empty list — that is "not fetched
31+
// yet", not "no agents", so the guard must keep waiting (not redirect).
32+
mockAgents.mockReturnValue(agentsResult([], false));
2533
const { result } = renderHook(() => useAiSurfaceEnabled());
26-
expect(result.current).toEqual({ enabled: false, isLoading: false });
34+
expect(result.current).toEqual({ enabled: false, isLoading: true });
2735
});
2836

29-
it('reports loading while discovery is still resolving, so guards do not flash a redirect', () => {
30-
mockDiscovery.mockReturnValue({ isAiEnabled: false, isLoading: true } as any);
37+
it('reports loading while the catalog fetch is in flight', () => {
38+
mockAgents.mockReturnValue(agentsResult([], true));
3139
const { result } = renderHook(() => useAiSurfaceEnabled());
3240
expect(result.current).toEqual({ enabled: false, isLoading: true });
3341
});
42+
43+
it('is disabled — not loading — once a fetch resolves empty (Community Edition: no agents)', () => {
44+
// Simulate the real lifecycle: fetch in flight → resolves with an empty
45+
// catalog. The latch records that a fetch ran, so the empty result is now
46+
// definitive and the guard redirects instead of spinning forever.
47+
mockAgents.mockReturnValue(agentsResult([], true));
48+
const { result, rerender } = renderHook(() => useAiSurfaceEnabled());
49+
expect(result.current.isLoading).toBe(true);
50+
51+
mockAgents.mockReturnValue(agentsResult([], false));
52+
rerender();
53+
expect(result.current).toEqual({ enabled: false, isLoading: false });
54+
});
3455
});
Lines changed: 52 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,52 @@
11
/**
22
* useAiSurfaceEnabled
33
*
4-
* Single source of truth for "should the in-UI AI surface be shown on this
5-
* deployment?". The console ships under MIT and is edition-agnostic: it decides
6-
* purely at runtime from what the server reports — no `VITE_EDITION` flag, no
7-
* tree-shake.
4+
* Single source of truth for "should the in-UI AI surface be shown — for THIS
5+
* user, on this deployment?". The console ships under MIT and is edition- and
6+
* seat-agnostic at build time; it decides purely at runtime from what the
7+
* server reports — no `VITE_EDITION` flag, no tree-shake.
88
*
9-
* The signal is whether the **`@objectstack/service-ai` capability is present**,
10-
* as reported by discovery (`/discovery` → `services.ai.enabled &&
11-
* status === 'available'`, i.e. `isAiEnabled`).
9+
* The signal is **the agent catalog** (`GET /api/v1/ai/agents`): the surface
10+
* shows iff that returns >= 1 agent. The catalog is the right signal because it
11+
* is the ONLY one that is BOTH edition- AND user-aware:
1212
*
13-
* `service-ai` is an ENTERPRISE capability: a Community-Edition runtime does not
14-
* depend on it, so the framework never registers the AI service and discovery
15-
* reports `services.ai` unavailable → the whole AI surface hides. An install
16-
* that ships `service-ai` reports it available → AI shows. It is the presence of
17-
* the CAPABILITY that gates, NOT whether any specific agent happens to be
18-
* configured yet (an install with `service-ai` but no agents has AI "available";
19-
* AiChatPage degrades gracefully if the catalog is empty).
13+
* • The route is access-filtered server-side (ADR-0049 / ADR-0068): it returns
14+
* only the agents the CALLER may chat. A user WITHOUT the per-user AI seat
15+
* (the `ai_seat` permission) gets an EMPTY catalog -> the whole AI surface
16+
* hides for them, instead of showing a button that 403s on click. The
17+
* deployment-wide discovery `services.ai` flag CANNOT express this — it is
18+
* identical for every user — which is exactly why we do NOT gate on it.
19+
* • It is ALSO the honest edition signal: a Community-Edition runtime that
20+
* ships no `@objectstack/service-ai` registers no AI service and persists no
21+
* agents -> empty catalog -> hidden. (The old "headless service reports
22+
* available in CE" worry is moot: empty catalog hides the surface either way.)
2023
*
21-
* The framework only registers the AI service when the host app declares
22-
* `@objectstack/service-ai`, so discovery's `services.ai` is an honest edition
23-
* signal (see objectstack-ai/framework#2311). Earlier this hook gated on the
24-
* agent catalog as a workaround for the headless service reporting itself
25-
* available in CE; with #2311 that no longer happens, so discovery is correct.
24+
* ⚠️ Do NOT "simplify" this back to `discovery.services.ai` (isAiEnabled): that
25+
* reintroduces the per-user gap — seat-less users would see the FAB / links and
26+
* hit 403 on click. The per-user AI-seat gate (ADR-0068) DEPENDS on this catalog
27+
* signal. (This reverts objectui#1992, which dropped the per-user dimension.)
2628
*
27-
* `VITE_AI_BASE_URL` is an explicit opt-in: it points the console at an external
28-
* AI server and is trusted even when local discovery reports AI unavailable.
29+
* The `VITE_AI_BASE_URL` opt-in flows through naturally: {@link resolveAiApiBase}
30+
* points the catalog fetch at the configured server, so an external AI server
31+
* with reachable agents lights the surface up and an agent-less one keeps it hidden.
2932
*
30-
* `isLoading` is surfaced so the `/ai` route guard can wait for discovery to
33+
* `isLoading` is surfaced so the `/ai` route guard can wait for the catalog to
3134
* resolve before redirecting — otherwise a stale bookmark would flash a redirect
32-
* to home before the server's answer is in.
35+
* to home before the fetch even starts. Entry-point buttons (FAB, top-bar link,
36+
* designer "Ask AI") ignore it: staying hidden during the brief load is the
37+
* correct, flash-free behaviour for a control that must not appear unless AI can
38+
* actually answer.
3339
*
3440
* @module
3541
*/
3642

37-
import { useDiscovery } from '@object-ui/react';
43+
import { useRef } from 'react';
44+
import { useAgents } from '@object-ui/plugin-chatbot';
3845

3946
/**
4047
* Resolve the AI service base URL, mirroring AiChatPage / the Home CTAs:
4148
* an explicit `VITE_AI_BASE_URL` wins, otherwise `${VITE_SERVER_URL}/api/v1/ai`.
42-
* Shared so every AI fetch (Home catalog, AiChatPage) hits the same URL.
49+
* Shared so every catalog fetch (route guard, layouts, Home) hits the same URL.
4350
*/
4451
export function resolveAiApiBase(): string {
4552
const env = (import.meta as any).env ?? {};
@@ -50,22 +57,32 @@ export function resolveAiApiBase(): string {
5057
}
5158

5259
export interface AiSurfaceState {
53-
/** True when the AI capability (`service-ai`) is available, so the AI UI shows. */
60+
/** True when the AI UI should render — the CALLER can reach >= 1 agent (access-filtered). */
5461
enabled: boolean;
55-
/** True until discovery resolves (and no `VITE_AI_BASE_URL` opt-in); guards wait on this. */
62+
/** True until the agent catalog has resolved; route guards wait on this. */
5663
isLoading: boolean;
5764
}
5865

5966
/**
6067
* Whether the console's AI surface (FAB, `/ai` routes, "Ask AI" affordances)
61-
* should be shown — driven off the presence of the `service-ai` capability in
62-
* discovery.
68+
* should be shown FOR THE CURRENT USER, driven off the access-filtered agent
69+
* catalog (empty for seat-less users -> AI hidden; ADR-0068).
6370
*/
6471
export function useAiSurfaceEnabled(): AiSurfaceState {
65-
const { isAiEnabled, isLoading } = useDiscovery();
66-
// An explicit external-AI opt-in is trusted even if local discovery reports AI
67-
// unavailable; it's synchronous, so there's nothing to wait for.
68-
const aiBaseUrlConfigured = Boolean((import.meta as any).env?.VITE_AI_BASE_URL);
69-
if (aiBaseUrlConfigured) return { enabled: true, isLoading: false };
70-
return { enabled: isAiEnabled, isLoading };
72+
const { agents, isLoading } = useAgents({ apiBase: resolveAiApiBase() });
73+
74+
// useAgents starts `isLoading=false` and only kicks off the fetch in an effect
75+
// a tick later, so the first render's empty list means "not fetched yet", not
76+
// "no agents". Latch whether a fetch has actually been in flight so the route
77+
// guard treats that initial frame as loading (not a definitive empty → redirect).
78+
const fetchStartedRef = useRef(false);
79+
if (isLoading) fetchStartedRef.current = true;
80+
81+
const enabled = agents.length > 0;
82+
return {
83+
enabled,
84+
// Agents present → resolved/available. Otherwise we're loading until a fetch
85+
// has both started and finished with an empty result.
86+
isLoading: enabled ? false : isLoading || !fetchStartedRef.current,
87+
};
7188
}

0 commit comments

Comments
 (0)