diff --git a/src/api/doormile/endpoints.js b/src/api/doormile/endpoints.js index 9adf4ae..f4ddd39 100644 --- a/src/api/doormile/endpoints.js +++ b/src/api/doormile/endpoints.js @@ -826,6 +826,16 @@ export const updateAiAgent = async (id, patch) => { * false when the backend is not subscribed to AI_engine telemetry. * @param {number} days */ +/** + * What is actually wired, for the Skills & Tools banner: whether AI_engine is + * reading the registry (it polls ~every 30 s; the backend records each read), + * telemetry, live agents, and whether the Test tab has a model. + */ +export const getAiStatus = async () => { + const response = await doormileAxios.get('/admin/ai/status'); + return response.data.data; +}; + export const getAiInsights = async (days = 7) => { const response = await doormileAxios.get(`/admin/ai/insights${buildQuery({ days })}`); return response.data.data; diff --git a/src/lib/doormileHooks.js b/src/lib/doormileHooks.js index 0d34a3d..be5dea6 100644 --- a/src/lib/doormileHooks.js +++ b/src/lib/doormileHooks.js @@ -865,6 +865,18 @@ export const useUpdateAiAgent = () => }); // Read-only, and it changes as agents work: refetched every minute while open. +// The banner's source of truth; re-checked every 30 s so it turns green on its +// own once AI_engine starts reading the registry. +export const useAiStatus = (options) => + useQuery({ + queryKey: [...KEYS.aiRegistry, 'status'], + queryFn: api.getAiStatus, + staleTime: 15_000, + refetchInterval: 30_000, + retry: false, + ...options, + }); + export const useAiInsights = (days = 7, options) => useQuery({ queryKey: [...KEYS.aiRegistry, 'insights', days], diff --git a/src/pages/doormile/settings/agentStudio/AgentStudio.jsx b/src/pages/doormile/settings/agentStudio/AgentStudio.jsx index c6c8838..d4c0804 100644 --- a/src/pages/doormile/settings/agentStudio/AgentStudio.jsx +++ b/src/pages/doormile/settings/agentStudio/AgentStudio.jsx @@ -5,7 +5,6 @@ import { Wrench, Bot, BarChart3, - AlertTriangle, Info, Loader2, } from 'lucide-react'; @@ -28,6 +27,7 @@ import SkillDetailsDrawer from './SkillDetailsDrawer'; import CreateSkillModal from './CreateSkillModal'; import ToolRegistryView from './ToolRegistryView'; import AgentPlayground from './AgentPlayground'; +import WiringStatusBanner from './WiringStatusBanner'; import { AgentConfigView, AgentInsightsView } from './AgentConfigAndInsights'; const TOP_NAV_TABS = [ @@ -122,22 +122,9 @@ export default function AgentStudio() { return (
- {/* Said on screen: what this page changes today, and what it does not. */} -
- -

- Half wired.{' '} - Changes here are saved to the Doormile agent registry and every change is audited. The Console Ops Agent's skills — the Exceptions early warnings and the - chat's “what needs attention” — run on these settings now. AI_engine's Dispatch, - Exception, Express Dispatch and Customer agents pick up changes within about 30 seconds once its - registry-reading build is deployed; until then, and whenever the registry cannot be reached, they run on - their environment defaults. Hub, Fleet and Route Optimizer are simulations. The Test tab runs real AI model - calls (reads redacted, writes only proposed) once the server has a model API key; until then it says so. -

-
+ {/* Said on screen: what these settings actually drive right now, from + GET /admin/ai/status — not a fixed note. */} + {!canEdit && (
diff --git a/src/pages/doormile/settings/agentStudio/WiringStatusBanner.jsx b/src/pages/doormile/settings/agentStudio/WiringStatusBanner.jsx new file mode 100644 index 0000000..e276a01 --- /dev/null +++ b/src/pages/doormile/settings/agentStudio/WiringStatusBanner.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { AlertTriangle, CheckCircle2, Info, XCircle } from 'lucide-react'; +import { useAiStatus } from '@/lib/doormileHooks'; + +/** + * What this page's settings actually drive, from GET /admin/ai/status — not a + * fixed note. It used to say "Half wired" forever, including after AI_engine + * and the backend were deployed, because nothing checked. + * + * Green only when AI_engine has read the registry recently (the backend stamps + * every poll) and the Test tab has a model. Re-checks every 30 seconds. + */ + +function ago(iso) { + if (!iso) return ''; + const s = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000)); + if (s < 60) return `${s}s ago`; + if (s < 3600) return `${Math.round(s / 60)} min ago`; + return new Date(iso).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }); +} + +/** The banner's lines, from a status payload. Pure, so it is tested directly. */ +export function wiringLines(status) { + const engine = status?.engine || {}; + const playground = status?.playground || {}; + return [ + { ok: true, text: 'Changes are saved to the Doormile agent registry, and every change is audited.' }, + { + ok: true, + text: 'The Console Ops Agent’s skills (Exceptions early warnings, the chat’s “what needs attention”) run on these settings.', + }, + engine.readingsettings + ? { + ok: true, + text: `AI_engine is following these settings (last read ${ago(engine.lastreadat)}); changes reach its Dispatch, Exception, Express Dispatch and Customer agents within about 30 seconds.`, + } + : { + ok: false, + text: engine.lastreadat + ? `AI_engine has not read these settings since ${ago(engine.lastreadat)}, so its agents are on their environment defaults. Check it is running and can reach this API (GO_API_BASE_URL, INTERNAL_API_KEY).` + : 'AI_engine has not read these settings yet, so its agents run on their environment defaults. It needs the registry-reading build, with GO_API_BASE_URL and INTERNAL_API_KEY pointing at this API.', + }, + playground.configured + ? { ok: true, text: `The Test tab is live${playground.model ? ` (model ${playground.model})` : ''}: reads are redacted, writes are only proposed.` } + : { ok: false, text: 'The Test tab is off: the server has no model API key (PLAYGROUND_LLM_API_KEY).' }, + { info: true, text: 'Hub, Fleet and Route Optimizer are simulations and are not affected by these settings.' }, + ]; +} + +export default function WiringStatusBanner() { + const { data, isLoading, isError, error } = useAiStatus(); + + if (isLoading) { + return ( +
+ Checking what is connected… +
+ ); + } + + if (isError) { + const notDeployed = error?.response?.status === 404; + return ( +
+ +

+ Connection status unavailable.{' '} + {notDeployed + ? 'This API server does not report agent status yet — deploy the current doormile_backend build. Changes here are still saved and audited.' + : 'The status check failed; it will retry shortly. Changes here are still saved and audited.'} +

+
+ ); + } + + const lines = wiringLines(data); + const allOk = lines.every((l) => l.ok || l.info); + + return ( +
+

+ {allOk ? 'All connected.' : 'Partly connected.'} +

+
    + {lines.map((l) => { + const Icon = l.info ? Info : l.ok ? CheckCircle2 : XCircle; + const color = l.info ? 'text-slate-400' : l.ok ? 'text-emerald-600' : 'text-amber-600'; + return ( +
  • + + {l.text} +
  • + ); + })} +
+
+ ); +} diff --git a/tests/integration/agentStudio.test.jsx b/tests/integration/agentStudio.test.jsx index 7763467..02e7ff9 100644 --- a/tests/integration/agentStudio.test.jsx +++ b/tests/integration/agentStudio.test.jsx @@ -25,6 +25,7 @@ jest.mock('@/api/doormile', () => ({ createAiSkill: jest.fn(), updateAiAgent: jest.fn(), getAiInsights: jest.fn(), + getAiStatus: jest.fn(), getAiDecisions: jest.fn(), runAiPlayground: jest.fn(), })); @@ -148,6 +149,7 @@ beforeEach(() => { api.updateAiAgent.mockResolvedValue({ success: true }); api.getAiInsights.mockResolvedValue(INSIGHTS); api.getAiDecisions.mockResolvedValue(DECISIONS); + api.getAiStatus.mockResolvedValue({ engine: { readingsettings: false, lastreadat: null }, playground: { configured: false } }); }); // ── Adapters ──────────────────────────────────────────────────────────────── @@ -184,18 +186,36 @@ describe('registry adapters', () => { // ── The page ──────────────────────────────────────────────────────────────── describe('Agent Studio page', () => { - it('says on screen which agents the registry drives, and when', async () => { + it('says "All connected" only when the engine reads the registry and the Test tab has a model', async () => { + api.getAiStatus.mockResolvedValue({ + engine: { readingsettings: true, lastreadat: new Date().toISOString(), telemetry: true, liveagents: 4 }, + playground: { configured: true, model: 'openai/gpt-oss-120b' }, + }); renderStudio(); - const note = screen.getByRole('note'); - expect(note).toHaveTextContent(/saved to the Doormile agent registry/); - // The engine reads it only once its Phase 5 build is deployed; say so, not "live". - expect(note).toHaveTextContent(/once its registry-reading build is deployed/); - expect(note).toHaveTextContent(/environment defaults/); - expect(note).toHaveTextContent(/The Test tab runs real AI model calls/); - expect(note).not.toHaveTextContent(/The Test tab is a simulation/); - // Since Phase 3 the console skills DO run on these settings; the note must not say otherwise. - expect(note).toHaveTextContent(/run on these settings now/); - expect(note).not.toHaveTextContent(/not merged/); + const note = await screen.findByText('All connected.'); + const banner = note.closest('[role="note"]'); + expect(banner).toHaveTextContent(/AI_engine is following these settings/); + expect(banner).toHaveTextContent(/The Test tab is live \(model openai\/gpt-oss-120b\)/); + expect(banner).not.toHaveTextContent(/Half wired/); + }); + + it('says what is missing when the engine has not read the registry', async () => { + api.getAiStatus.mockResolvedValue({ + engine: { readingsettings: false, lastreadat: null, telemetry: false, liveagents: 0 }, + playground: { configured: false }, + }); + renderStudio(); + const banner = (await screen.findByText('Partly connected.')).closest('[role="note"]'); + expect(banner).toHaveTextContent(/AI_engine has not read these settings yet/); + expect(banner).toHaveTextContent(/environment defaults/); + expect(banner).toHaveTextContent(/Test tab is off/); + expect(banner).toHaveTextContent(/saved to the Doormile agent registry/); + }); + + it('says the backend needs deploying when it has no status endpoint', async () => { + api.getAiStatus.mockRejectedValue({ response: { status: 404 } }); + renderStudio(); + expect(await screen.findByText(/does not report agent status yet/)).toBeInTheDocument(); }); it("shows only the selected agent's skills", async () => { @@ -257,7 +277,7 @@ describe('Agent Studio page', () => { renderStudio(); await screen.findAllByText('Stalled-rider response'); await switchTo('Console Ops Agent'); - fireEvent.click(await screen.findByText('New skill')); + fireEvent.click(await screen.findByText(/^New skill$/i)); fireEvent.change(screen.getByLabelText('Skill Title *'), { target: { value: 'Night shift watch' } }); fireEvent.change(screen.getByLabelText('Description *'), { target: { value: 'Watch the night shift.' } }); @@ -281,7 +301,7 @@ describe('Agent Studio page', () => { it('does not offer a new skill on an AI_engine agent', async () => { renderStudio(); await screen.findAllByText('Stalled-rider response'); - expect(screen.getByText('New skill').closest('button')).toBeDisabled(); + expect(screen.getByText(/^New skill$/i).closest('button')).toBeDisabled(); }); it('needs the agent id typed before switching autonomy on', async () => { @@ -319,7 +339,7 @@ describe('Agent Studio page', () => { renderStudio(); await screen.findAllByText('Stalled-rider response'); expect(screen.getByText('You can view the registry. Only an admin can change it.')).toBeInTheDocument(); - expect(screen.getByText('New skill').closest('button')).toBeDisabled(); + expect(screen.getByText(/^New skill$/i).closest('button')).toBeDisabled(); fireEvent.click(screen.getByText('Details')); expect(screen.getByRole('switch', { name: 'Stalled-rider response enabled' })).toBeDisabled(); @@ -331,12 +351,16 @@ describe('Agent Studio page', () => { renderStudio(); await screen.findAllByText('Stalled-rider response'); fireEvent.click(screen.getAllByText('Tools')[0]); - const row = screen.getAllByText('nearby_milers').map((el) => el.closest('tr')).find(Boolean); - expect(within(row).getByText('Read-only')).toBeInTheDocument(); - expect(within(row).getByText('No confirmation needed')).toBeInTheDocument(); - const gated = screen.getAllByText('reassign_booking').map((el) => el.closest('tr')).find(Boolean); - expect(within(gated).getByText('Writes')).toBeInTheDocument(); - expect(within(gated).getByText('Confirmation Required')).toBeInTheDocument(); + // Tools render as cards (a header button per tool): kind + safety badges. + // The name also appears in the agent navigator; the tool card is the button carrying its kind badge. + const toolCard = (name) => + screen.getAllByText(name).map((el) => el.closest('button')).find((btn) => btn && within(btn).queryByText(/^(Read|Write|Notify|General)$/)); + const card = toolCard('nearby_milers'); + expect(within(card).getByText('Read')).toBeInTheDocument(); + expect(within(card).getByText('Open')).toBeInTheDocument(); + const gated = toolCard('reassign_booking'); + expect(within(gated).getByText('Write')).toBeInTheDocument(); + expect(within(gated).getByText('Gated')).toBeInTheDocument(); expect(screen.queryByText('Autonomous')).not.toBeInTheDocument(); }); @@ -406,7 +430,8 @@ describe('Agent Studio Test tab', () => { const openTest = async () => { renderStudio(); await screen.findAllByText('Stalled-rider response'); - fireEvent.click(screen.getByText('Test')); + // The tab row renders before the skill cards, whose buttons also say Test. + fireEvent.click(screen.getAllByText('Test')[0]); }; it('runs the prompt on the backend and shows its real trace', async () => {