Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/ai-gate-on-service-ai.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@object-ui/app-shell": patch
---

fix(console): gate the AI surface on the `service-ai` capability (discovery), not the agent catalog

`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 happens to be configured yet.

The earlier catalog-based gating was a workaround for the headless service
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 no longer needed. Everything else stays: the centralized
hook, the `RequireAiSurface` `/ai` route guard, the gated top-bar link + designer
"Ask AI" buttons, and AiChatPage's graceful empty state.
53 changes: 16 additions & 37 deletions packages/app-shell/src/hooks/__tests__/useAiSurface.test.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,34 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useAgents } from '@object-ui/plugin-chatbot';
import { useDiscovery } from '@object-ui/react';
import { useAiSurfaceEnabled } from '../useAiSurface';

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

function agentsResult(names: string[], isLoading: boolean) {
return {
agents: names.map((name) => ({ name, label: name })),
isLoading,
error: undefined,
refetch: vi.fn(),
};
}

afterEach(() => mockAgents.mockReset());
afterEach(() => mockDiscovery.mockReset());

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

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

it('reports loading while the catalog fetch is in flight', () => {
mockAgents.mockReturnValue(agentsResult([], true));
it('reports loading while discovery is still resolving, so guards do not flash a redirect', () => {
mockDiscovery.mockReturnValue({ isAiEnabled: false, isLoading: true } as any);
const { result } = renderHook(() => useAiSurfaceEnabled());
expect(result.current).toEqual({ enabled: false, isLoading: true });
});

it('is disabled — not loading — once a fetch resolves empty (Community Edition: no agents)', () => {
// Simulate the real lifecycle: fetch in flight → resolves with an empty
// catalog. The latch records that a fetch ran, so the empty result is now
// definitive and the guard redirects instead of spinning forever.
mockAgents.mockReturnValue(agentsResult([], true));
const { result, rerender } = renderHook(() => useAiSurfaceEnabled());
expect(result.current.isLoading).toBe(true);

mockAgents.mockReturnValue(agentsResult([], false));
rerender();
expect(result.current).toEqual({ enabled: false, isLoading: false });
});
});
81 changes: 35 additions & 46 deletions packages/app-shell/src/hooks/useAiSurface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,44 @@
* useAiSurfaceEnabled
*
* Single source of truth for "should the in-UI AI surface be shown on this
* deployment?". The console ships under MIT and is edition-agnostic: it never
* knows at build time whether the runtime is a Community Edition (framework
* only, no cloud AI package) or a full cloud install. It decides purely at
* runtime from what the server reports — no `VITE_EDITION` flag, no tree-shake.
* deployment?". The console ships under MIT and is edition-agnostic: it decides
* purely at runtime from what the server reports — no `VITE_EDITION` flag, no
* tree-shake.
*
* The signal is **a non-empty agent catalog** (`GET /api/v1/ai/agents`), NOT
* the discovery `services.ai` flag. That distinction matters:
* The signal is whether the **`@objectstack/service-ai` capability is present**,
* as reported by discovery (`/discovery` → `services.ai.enabled &&
* status === 'available'`, i.e. `isAiEnabled`).
*
* The `ask`/`build` agent *personas* are a commercial feature that moved to
* the cloud-only `@objectstack/service-ai-studio` package; the open-source
* framework keeps a HEADLESS `@objectstack/service-ai` that still
* `registerService('ai')`s. So on a Community Edition runtime discovery can
* STILL report `services.ai` as available (the service is running) while the
* agent catalog is empty (no persona attached). Gating on `isAiEnabled` would
* then leave the FAB / "Ask AI" affordances visible with nothing to talk to —
* a dead end. The catalog is the real "is there an agent to answer?" signal,
* and it's exactly what the Home "Build/Ask AI" CTAs already gate on, so every
* AI entry point now agrees.
* `service-ai` is an ENTERPRISE capability: a Community-Edition runtime does not
* depend on it, so the framework never registers the AI service and discovery
* reports `services.ai` unavailable → the whole AI surface hides. An install
* that ships `service-ai` reports it available → AI shows. It is the presence of
* the CAPABILITY that gates, NOT whether any specific agent happens to be
* configured yet (an install with `service-ai` but no agents has AI "available";
* AiChatPage degrades gracefully if the catalog is empty).
*
* The `VITE_AI_BASE_URL` opt-in flows through naturally: {@link resolveAiApiBase}
* points the catalog fetch at the configured server, so an external AI server
* with agents lights the surface up and an agent-less one keeps it hidden.
* The framework only registers the AI service when the host app declares
* `@objectstack/service-ai`, so discovery's `services.ai` is an honest edition
* signal (see objectstack-ai/framework#2311). Earlier this hook gated on the
* agent catalog as a workaround for the headless service reporting itself
* available in CE; with #2311 that no longer happens, so discovery is correct.
*
* `isLoading` is surfaced so the `/ai` route guard can wait for the catalog to
* `VITE_AI_BASE_URL` is an explicit opt-in: it points the console at an external
* AI server and is trusted even when local discovery reports AI unavailable.
*
* `isLoading` is surfaced so the `/ai` route guard can wait for discovery to
* resolve before redirecting — otherwise a stale bookmark would flash a redirect
* to home before the fetch even starts. Entry-point buttons (FAB, top-bar link,
* designer "Ask AI") ignore it: staying hidden during the brief load is the
* correct, flash-free behaviour for a control that must not appear unless AI can
* actually answer.
* to home before the server's answer is in.
*
* @module
*/

import { useRef } from 'react';
import { useAgents } from '@object-ui/plugin-chatbot';
import { useDiscovery } from '@object-ui/react';

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

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

/**
* Whether the console's AI surface (FAB, `/ai` routes, "Ask AI" affordances)
* should be shown, driven off the live agent catalog.
* should be shown — driven off the presence of the `service-ai` capability in
* discovery.
*/
export function useAiSurfaceEnabled(): AiSurfaceState {
const { agents, isLoading } = useAgents({ apiBase: resolveAiApiBase() });

// useAgents starts `isLoading=false` and only kicks off the fetch in an effect
// a tick later, so the first render's empty list means "not fetched yet", not
// "no agents". Latch whether a fetch has actually been in flight so the route
// guard treats that initial frame as loading (not a definitive empty → redirect).
const fetchStartedRef = useRef(false);
if (isLoading) fetchStartedRef.current = true;

const enabled = agents.length > 0;
return {
enabled,
// Agents present → resolved/available. Otherwise we're loading until a fetch
// has both started and finished with an empty result.
isLoading: enabled ? false : isLoading || !fetchStartedRef.current,
};
const { isAiEnabled, isLoading } = useDiscovery();
// An explicit external-AI opt-in is trusted even if local discovery reports AI
// unavailable; it's synchronous, so there's nothing to wait for.
const aiBaseUrlConfigured = Boolean((import.meta as any).env?.VITE_AI_BASE_URL);
if (aiBaseUrlConfigured) return { enabled: true, isLoading: false };
return { enabled: isAiEnabled, isLoading };
}
Loading