updates on the hook and endpoints for the ai agents
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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'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.
|
||||
</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">
|
||||
|
||||
108
src/pages/doormile/settings/agentStudio/WiringStatusBanner.jsx
Normal file
108
src/pages/doormile/settings/agentStudio/WiringStatusBanner.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user