updates on the hook and endpoints for the ai agents

This commit is contained in:
2026-09-30 15:08:33 +05:30
parent ede1731fa5
commit d33fff01d3
5 changed files with 180 additions and 38 deletions

View File

@@ -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;

View File

@@ -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],

View File

@@ -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 (
<div className="space-y-5 pb-10">
{/* Said on screen: what this page changes today, and what it does not. */}
<div
role="note"
className="flex items-start gap-3 rounded-2xl border border-amber-200/80 bg-gradient-to-r from-amber-50 to-orange-50 px-4 py-3.5 text-xs text-amber-900 shadow-sm"
>
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-500" />
<p className="leading-relaxed">
<span className="font-bold text-amber-800">Half wired.</span>{' '}
Changes here are saved to the Doormile agent registry and every change is audited. The Console Ops Agent&apos;s skills — the Exceptions early warnings and the
chat&apos;s &ldquo;what needs attention&rdquo; — run on these settings now. AI_engine&apos;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.
</p>
</div>
{/* Said on screen: what these settings actually drive right now, from
GET /admin/ai/status — not a fixed note. */}
<WiringStatusBanner />
{!canEdit && (
<div className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-xs text-slate-600">

View File

@@ -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 (
<div role="note" className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3.5 text-xs text-slate-500">
Checking what is connected…
</div>
);
}
if (isError) {
const notDeployed = error?.response?.status === 404;
return (
<div
role="note"
className="flex items-start gap-3 rounded-2xl border border-amber-200/80 bg-gradient-to-r from-amber-50 to-orange-50 px-4 py-3.5 text-xs text-amber-900 shadow-sm"
>
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-500" />
<p className="leading-relaxed">
<span className="font-bold text-amber-800">Connection status unavailable.</span>{' '}
{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.'}
</p>
</div>
);
}
const lines = wiringLines(data);
const allOk = lines.every((l) => l.ok || l.info);
return (
<div
role="note"
className={
allOk
? 'rounded-2xl border border-emerald-200/80 bg-gradient-to-r from-emerald-50 to-teal-50 px-4 py-3.5 text-xs text-emerald-900 shadow-sm'
: 'rounded-2xl border border-amber-200/80 bg-gradient-to-r from-amber-50 to-orange-50 px-4 py-3.5 text-xs text-amber-900 shadow-sm'
}
>
<p className={`font-bold mb-1.5 ${allOk ? 'text-emerald-800' : 'text-amber-800'}`}>
{allOk ? 'All connected.' : 'Partly connected.'}
</p>
<ul className="space-y-1">
{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 (
<li key={l.text} className="flex items-start gap-2 leading-relaxed">
<Icon className={`w-3.5 h-3.5 mt-0.5 shrink-0 ${color}`} />
<span>{l.text}</span>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -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 () => {