- {/* Said on screen: what this page changes today, and what it does not. */}
-
+ {/* Said on screen: what these settings actually drive right now, from
+ GET /admin/ai/status — not a fixed note. */}
+
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 () => {