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
12 changes: 12 additions & 0 deletions .changeset/11799-studio-expected-misses.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/data-objectstack': patch
'@object-ui/app-shell': patch
---

Studio stops asking questions whose expected answer is an error, so the browser console no longer fills with red lines that hide real failures (objectui#11799).

- **`MetadataClient.getDraft()` reads the drafts ledger first.** It reads `GET /meta/_drafts` through the client's shared read, and sends `GET /meta/:type/:name?state=draft` only when the ledger lists the name. An item with no draft no longer logs a 404. The method still resolves `null` when there is no draft and the draft envelope when there is one. A draft saved a moment ago is found, because a write drops the ledger read that was pending when it landed. A ledger the caller cannot read (403, 501, a network fault) counts as unknown, and the item read is sent as before.
- **Studio reads an item's published baseline only when it uses it.** The Interfaces pillar's leaves and the Automations pillar's flows read `GET /meta/:type/:name/layers` only for an item with no pending draft, and the Interfaces pillar reads none for an app found only in the drafts ledger. An item that was never saved always has a draft, so it no longer logs a 404 for `/layers`. Opening a leaf or a flow now waits for the ledger before it reads the item, one round trip more than the parallel reads it replaces. The Data pillar still reads `/layers` for an object that was never published: that answer is how it tells it has no records table yet.
- **The AI usage indicator asks nothing while AI is off.** It reads `GET /ai/usage` only while the agent catalog at its base lists an agent, the signal the console gates every AI entry point on. On an open-edition server, Studio's copilot dock no longer logs a 501 for it each time it mounts.

No REST answer, export, prop or type member changes.
15 changes: 14 additions & 1 deletion packages/app-shell/src/layout/AiUsageIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,19 @@
* (endpoint absent on an older backend, OSS, no seat) or the pool is unmetered, the
* whole indicator renders nothing — a missing endpoint degrades to no widget, never
* a broken one.
*
* objectui#11799 — and with AI off it asks nothing. AI is on for this viewer when
* the access-filtered agent catalog at the same base (`GET {apiBase}/agents`)
* lists an agent: the signal `useAiSurfaceEnabled` gates every AI entry point
* on, whose header says why it is not discovery's `services.ai`. An open-edition
* server answers that catalog with its empty-list courtesy and every other
* `/ai/*` route with 501, so the Studio dock — drawn while the catalog loads —
* used to log a 501 for the usage read on every mount.
*/
import * as React from 'react';
import { cn, Button, Popover, PopoverTrigger, PopoverContent } from '@object-ui/components';
import { formatNumber, useObjectTranslation } from '@object-ui/i18n';
import { useAgents } from '@object-ui/plugin-chatbot';
import { useAiUsage, type AiMeterUsage } from '../hooks/useAiUsage.js';
import { cloudConsoleUrl } from '../console/marketplace/marketplaceApi.js';

Expand Down Expand Up @@ -130,7 +139,11 @@ export interface AiUsageIndicatorProps {
*/
export function AiUsageIndicator({ apiBase, enabled = true, className }: AiUsageIndicatorProps) {
const { t, language } = useObjectTranslation();
const { usage } = useAiUsage({ apiBase, enabled });
// objectui#11799 — no usage read until the catalog lists an agent (see the
// file header). The catalog read is shared with the dock's own: `useAgents`
// keeps one request per base in flight and its answer for a short while.
const { agents } = useAgents({ apiBase, enabled });
const { usage } = useAiUsage({ apiBase, enabled: enabled && agents.length > 0 });

// "Now", read OUTSIDE render (react-hooks/purity forbids `Date.now()` in the
// render body — it is non-deterministic and the compiler assumes render can
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* objectui#11799 — with AI off, the usage indicator never asks for usage.
*
* An open-edition server answers `GET /api/v1/ai/agents` with its empty-list
* courtesy and every other `/ai/*` route with 501. The Studio dock is drawn
* while the agent catalog loads, so its indicator mounted, read
* `GET /api/v1/ai/usage` and logged a 501 on every mount. The indicator now
* reads usage only while the catalog at its own base lists an agent, the signal
* `useAiSurfaceEnabled` gates every AI entry point on.
*
* The REAL `useAgents` and the REAL `useAiUsage`, over a stubbed fetch that
* records every URL. Each case uses its own base, because `useAgents` keeps a
* catalog answer per base for a short while.
*/
import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import { AI_USAGE_REFRESH_EVENT } from '@object-ui/plugin-chatbot';

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
language: 'en',
}),
}));
vi.mock('../../console/marketplace/marketplaceApi', () => ({
cloudConsoleUrl: () => 'https://cloud.example',
}));

import { AiUsageIndicator } from '../AiUsageIndicator';

const POOL = {
planType: 'free',
fraction: 0.3,
unmetered: false,
resetKind: 'daily',
resetsAt: null,
upgrade: false,
topUp: false,
};

const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });

/** A server whose catalog lists `agents`, recording every URL it is asked. */
function serverWith(agents: Array<{ name: string }>, hold?: Promise<void>) {
const urls: string[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
urls.push(url);
if (url.endsWith('/agents')) {
if (hold) await hold;
return json(200, { success: true, data: { agents } });
}
if (url.endsWith('/usage')) return agents.length ? json(200, { pool: POOL }) : json(501, { error: { code: 'NOT_IMPLEMENTED' } });
return json(404, {});
}),
);
return { usageReads: () => urls.filter((u) => u.endsWith('/usage')).length, urls };
}

async function settle() {
await act(async () => {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
});
}

/** What the indicator re-reads on: the chat engine's nudge, and tab re-focus. */
async function nudge() {
await act(async () => {
window.dispatchEvent(new CustomEvent(AI_USAGE_REFRESH_EVENT));
document.dispatchEvent(new Event('visibilitychange'));
});
await settle();
}

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

describe('AiUsageIndicator — no usage read with AI off (objectui#11799)', () => {
it('an empty agent catalog: /ai/usage is never requested, not on mount and not on a nudge', async () => {
const server = serverWith([]);
const { container } = render(<AiUsageIndicator apiBase="/api/v1/ai-off" />);
await settle();
await nudge();
expect(server.usageReads()).toBe(0);
expect(container).toBeEmptyDOMElement();
// The "never" above is read after the catalog answered empty.
expect(server.urls).toContain('/api/v1/ai-off/agents');
});

it('while the catalog is still loading, usage is not read yet', async () => {
let release!: () => void;
const server = serverWith([{ name: 'ask' }], new Promise<void>((r) => (release = r)));
render(<AiUsageIndicator apiBase="/api/v1/ai-loading" />);
await waitFor(() => expect(server.urls).toContain('/api/v1/ai-loading/agents'));
await settle();
expect(server.usageReads()).toBe(0);

await act(async () => release());
await waitFor(() => expect(server.usageReads()).toBe(1));
});

it('control: a catalog that lists an agent reads usage on mount and again on a nudge', async () => {
const server = serverWith([{ name: 'ask' }]);
render(<AiUsageIndicator apiBase="/api/v1/ai-on" />);
expect(await screen.findByTestId('ai-usage-indicator')).toBeInTheDocument();
expect(server.usageReads()).toBe(1);

await nudge();
expect(server.usageReads()).toBeGreaterThan(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ vi.mock('../../console/marketplace/marketplaceApi', () => ({
cloudConsoleUrl: () => 'https://cloud.example',
}));
vi.mock('../../hooks/useAiUsage', () => ({ useAiUsage: vi.fn() }));
// objectui#11799 — the indicator reads usage only while the agent catalog lists
// an agent, so every case here is a viewer with AI on. AI off is pinned in
// `AiUsageIndicator.aiOff-11799.test.tsx`, over the real catalog hook.
vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAgents: () => ({ agents: [{ name: 'ask', label: 'Ask' }], isLoading: false, error: undefined, refetch: () => {} }),
}));

import { useAiUsage } from '../../hooks/useAiUsage';
import { AiUsageIndicator } from '../AiUsageIndicator';
Expand Down
Loading
Loading