Skip to content

Commit 8a3b351

Browse files
os-zhuangclaude
andauthored
fix(console): gate the AI surface on the service-ai capability, not the agent catalog (#1992)
useAiSurfaceEnabled now keys off discovery's `services.ai` (isAiEnabled) — i.e. whether the enterprise @objectstack/service-ai capability is present — instead of a non-empty agent catalog. service-ai is an enterprise capability; a Community-Edition runtime doesn't ship it, so the framework doesn't register the AI service and discovery reports services.ai unavailable → the whole AI surface hides. An install that has service-ai reports it available → AI shows. The presence of the CAPABILITY gates, not whether a specific agent is configured yet. The earlier catalog-based gating was a workaround for the headless service-ai reporting itself available in CE. The framework now only registers the AI service when the host app declares @objectstack/service-ai (objectstack-ai/framework#2311), so discovery is an honest edition signal and the catalog detour is gone. Everything else from the original gating stays: the centralized hook, the RequireAiSurface /ai route guard, the gated top-bar link + designer "Ask AI" buttons, and AiChatPage's graceful empty state. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0caea33 commit 8a3b351

3 files changed

Lines changed: 74 additions & 83 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
fix(console): gate the AI surface on the `service-ai` capability (discovery), not the agent catalog
6+
7+
`useAiSurfaceEnabled` now keys off discovery's `services.ai` (`isAiEnabled`) —
8+
i.e. whether the enterprise `@objectstack/service-ai` capability is present —
9+
instead of a non-empty agent catalog.
10+
11+
`service-ai` is an enterprise capability: a Community-Edition runtime doesn't
12+
ship it, so the framework doesn't register the AI service and discovery reports
13+
`services.ai` unavailable → the whole AI surface hides. An install that has
14+
`service-ai` reports it available → AI shows. The presence of the CAPABILITY
15+
gates, not whether a specific agent happens to be configured yet.
16+
17+
The earlier catalog-based gating was a workaround for the headless service
18+
reporting itself available in CE; the framework now only registers the AI
19+
service when the host app declares `@objectstack/service-ai`
20+
(objectstack-ai/framework#2311), so discovery is an honest edition signal and
21+
the catalog detour is no longer needed. Everything else stays: the centralized
22+
hook, the `RequireAiSurface` `/ai` route guard, the gated top-bar link + designer
23+
"Ask AI" buttons, and AiChatPage's graceful empty state.
Lines changed: 16 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,34 @@
11
import { describe, it, expect, vi, afterEach } from 'vitest';
22
import { renderHook } from '@testing-library/react';
3-
import { useAgents } from '@object-ui/plugin-chatbot';
3+
import { useDiscovery } from '@object-ui/react';
44
import { useAiSurfaceEnabled } from '../useAiSurface';
55

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);
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);
1013

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());
14+
afterEach(() => mockDiscovery.mockReset());
2115

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

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));
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);
3325
const { result } = renderHook(() => useAiSurfaceEnabled());
34-
expect(result.current).toEqual({ enabled: false, isLoading: true });
26+
expect(result.current).toEqual({ enabled: false, isLoading: false });
3527
});
3628

37-
it('reports loading while the catalog fetch is in flight', () => {
38-
mockAgents.mockReturnValue(agentsResult([], true));
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);
3931
const { result } = renderHook(() => useAiSurfaceEnabled());
4032
expect(result.current).toEqual({ enabled: false, isLoading: true });
4133
});
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-
});
5534
});

packages/app-shell/src/hooks/useAiSurface.ts

Lines changed: 35 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,44 @@
22
* useAiSurfaceEnabled
33
*
44
* 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 never
6-
* knows at build time whether the runtime is a Community Edition (framework
7-
* only, no cloud AI package) or a full cloud install. It decides purely at
8-
* runtime from what the server reports — no `VITE_EDITION` flag, no tree-shake.
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.
98
*
10-
* The signal is **a non-empty agent catalog** (`GET /api/v1/ai/agents`), NOT
11-
* the discovery `services.ai` flag. That distinction matters:
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`).
1212
*
13-
* The `ask`/`build` agent *personas* are a commercial feature that moved to
14-
* the cloud-only `@objectstack/service-ai-studio` package; the open-source
15-
* framework keeps a HEADLESS `@objectstack/service-ai` that still
16-
* `registerService('ai')`s. So on a Community Edition runtime discovery can
17-
* STILL report `services.ai` as available (the service is running) while the
18-
* agent catalog is empty (no persona attached). Gating on `isAiEnabled` would
19-
* then leave the FAB / "Ask AI" affordances visible with nothing to talk to —
20-
* a dead end. The catalog is the real "is there an agent to answer?" signal,
21-
* and it's exactly what the Home "Build/Ask AI" CTAs already gate on, so every
22-
* AI entry point now agrees.
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).
2320
*
24-
* The `VITE_AI_BASE_URL` opt-in flows through naturally: {@link resolveAiApiBase}
25-
* points the catalog fetch at the configured server, so an external AI server
26-
* with agents lights the surface up and an agent-less one keeps it hidden.
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.
2726
*
28-
* `isLoading` is surfaced so the `/ai` route guard can wait for the catalog to
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+
*
30+
* `isLoading` is surfaced so the `/ai` route guard can wait for discovery to
2931
* resolve before redirecting — otherwise a stale bookmark would flash a redirect
30-
* to home before the fetch even starts. Entry-point buttons (FAB, top-bar link,
31-
* designer "Ask AI") ignore it: staying hidden during the brief load is the
32-
* correct, flash-free behaviour for a control that must not appear unless AI can
33-
* actually answer.
32+
* to home before the server's answer is in.
3433
*
3534
* @module
3635
*/
3736

38-
import { useRef } from 'react';
39-
import { useAgents } from '@object-ui/plugin-chatbot';
37+
import { useDiscovery } from '@object-ui/react';
4038

4139
/**
4240
* Resolve the AI service base URL, mirroring AiChatPage / the Home CTAs:
4341
* an explicit `VITE_AI_BASE_URL` wins, otherwise `${VITE_SERVER_URL}/api/v1/ai`.
44-
* Shared so every catalog fetch (route guard, layouts, Home) hits the same URL.
42+
* Shared so every AI fetch (Home catalog, AiChatPage) hits the same URL.
4543
*/
4644
export function resolveAiApiBase(): string {
4745
const env = (import.meta as any).env ?? {};
@@ -52,31 +50,22 @@ export function resolveAiApiBase(): string {
5250
}
5351

5452
export interface AiSurfaceState {
55-
/** True when the AI UI should be rendered (the server serves ≥1 agent). */
53+
/** True when the AI capability (`service-ai`) is available, so the AI UI shows. */
5654
enabled: boolean;
57-
/** True until the agent catalog has resolved; route guards wait on this. */
55+
/** True until discovery resolves (and no `VITE_AI_BASE_URL` opt-in); guards wait on this. */
5856
isLoading: boolean;
5957
}
6058

6159
/**
6260
* Whether the console's AI surface (FAB, `/ai` routes, "Ask AI" affordances)
63-
* should be shown, driven off the live agent catalog.
61+
* should be shown — driven off the presence of the `service-ai` capability in
62+
* discovery.
6463
*/
6564
export function useAiSurfaceEnabled(): AiSurfaceState {
66-
const { agents, isLoading } = useAgents({ apiBase: resolveAiApiBase() });
67-
68-
// useAgents starts `isLoading=false` and only kicks off the fetch in an effect
69-
// a tick later, so the first render's empty list means "not fetched yet", not
70-
// "no agents". Latch whether a fetch has actually been in flight so the route
71-
// guard treats that initial frame as loading (not a definitive empty → redirect).
72-
const fetchStartedRef = useRef(false);
73-
if (isLoading) fetchStartedRef.current = true;
74-
75-
const enabled = agents.length > 0;
76-
return {
77-
enabled,
78-
// Agents present → resolved/available. Otherwise we're loading until a fetch
79-
// has both started and finished with an empty result.
80-
isLoading: enabled ? false : isLoading || !fetchStartedRef.current,
81-
};
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 };
8271
}

0 commit comments

Comments
 (0)