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.
|
* false when the backend is not subscribed to AI_engine telemetry.
|
||||||
* @param {number} days
|
* @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) => {
|
export const getAiInsights = async (days = 7) => {
|
||||||
const response = await doormileAxios.get(`/admin/ai/insights${buildQuery({ days })}`);
|
const response = await doormileAxios.get(`/admin/ai/insights${buildQuery({ days })}`);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
|
|||||||
@@ -865,6 +865,18 @@ export const useUpdateAiAgent = () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Read-only, and it changes as agents work: refetched every minute while open.
|
// 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) =>
|
export const useAiInsights = (days = 7, options) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: [...KEYS.aiRegistry, 'insights', days],
|
queryKey: [...KEYS.aiRegistry, 'insights', days],
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
Bot,
|
Bot,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
AlertTriangle,
|
|
||||||
Info,
|
Info,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -28,6 +27,7 @@ import SkillDetailsDrawer from './SkillDetailsDrawer';
|
|||||||
import CreateSkillModal from './CreateSkillModal';
|
import CreateSkillModal from './CreateSkillModal';
|
||||||
import ToolRegistryView from './ToolRegistryView';
|
import ToolRegistryView from './ToolRegistryView';
|
||||||
import AgentPlayground from './AgentPlayground';
|
import AgentPlayground from './AgentPlayground';
|
||||||
|
import WiringStatusBanner from './WiringStatusBanner';
|
||||||
import { AgentConfigView, AgentInsightsView } from './AgentConfigAndInsights';
|
import { AgentConfigView, AgentInsightsView } from './AgentConfigAndInsights';
|
||||||
|
|
||||||
const TOP_NAV_TABS = [
|
const TOP_NAV_TABS = [
|
||||||
@@ -122,22 +122,9 @@ export default function AgentStudio() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 pb-10">
|
<div className="space-y-5 pb-10">
|
||||||
{/* Said on screen: what this page changes today, and what it does not. */}
|
{/* Said on screen: what these settings actually drive right now, from
|
||||||
<div
|
GET /admin/ai/status — not a fixed note. */}
|
||||||
role="note"
|
<WiringStatusBanner />
|
||||||
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>
|
|
||||||
|
|
||||||
{!canEdit && (
|
{!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">
|
<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(),
|
createAiSkill: jest.fn(),
|
||||||
updateAiAgent: jest.fn(),
|
updateAiAgent: jest.fn(),
|
||||||
getAiInsights: jest.fn(),
|
getAiInsights: jest.fn(),
|
||||||
|
getAiStatus: jest.fn(),
|
||||||
getAiDecisions: jest.fn(),
|
getAiDecisions: jest.fn(),
|
||||||
runAiPlayground: jest.fn(),
|
runAiPlayground: jest.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -148,6 +149,7 @@ beforeEach(() => {
|
|||||||
api.updateAiAgent.mockResolvedValue({ success: true });
|
api.updateAiAgent.mockResolvedValue({ success: true });
|
||||||
api.getAiInsights.mockResolvedValue(INSIGHTS);
|
api.getAiInsights.mockResolvedValue(INSIGHTS);
|
||||||
api.getAiDecisions.mockResolvedValue(DECISIONS);
|
api.getAiDecisions.mockResolvedValue(DECISIONS);
|
||||||
|
api.getAiStatus.mockResolvedValue({ engine: { readingsettings: false, lastreadat: null }, playground: { configured: false } });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Adapters ────────────────────────────────────────────────────────────────
|
// ── Adapters ────────────────────────────────────────────────────────────────
|
||||||
@@ -184,18 +186,36 @@ describe('registry adapters', () => {
|
|||||||
// ── The page ────────────────────────────────────────────────────────────────
|
// ── The page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Agent Studio 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();
|
renderStudio();
|
||||||
const note = screen.getByRole('note');
|
const note = await screen.findByText('All connected.');
|
||||||
expect(note).toHaveTextContent(/saved to the Doormile agent registry/);
|
const banner = note.closest('[role="note"]');
|
||||||
// The engine reads it only once its Phase 5 build is deployed; say so, not "live".
|
expect(banner).toHaveTextContent(/AI_engine is following these settings/);
|
||||||
expect(note).toHaveTextContent(/once its registry-reading build is deployed/);
|
expect(banner).toHaveTextContent(/The Test tab is live \(model openai\/gpt-oss-120b\)/);
|
||||||
expect(note).toHaveTextContent(/environment defaults/);
|
expect(banner).not.toHaveTextContent(/Half wired/);
|
||||||
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.
|
it('says what is missing when the engine has not read the registry', async () => {
|
||||||
expect(note).toHaveTextContent(/run on these settings now/);
|
api.getAiStatus.mockResolvedValue({
|
||||||
expect(note).not.toHaveTextContent(/not merged/);
|
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 () => {
|
it("shows only the selected agent's skills", async () => {
|
||||||
@@ -257,7 +277,7 @@ describe('Agent Studio page', () => {
|
|||||||
renderStudio();
|
renderStudio();
|
||||||
await screen.findAllByText('Stalled-rider response');
|
await screen.findAllByText('Stalled-rider response');
|
||||||
await switchTo('Console Ops Agent');
|
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('Skill Title *'), { target: { value: 'Night shift watch' } });
|
||||||
fireEvent.change(screen.getByLabelText('Description *'), { target: { value: 'Watch the night shift.' } });
|
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 () => {
|
it('does not offer a new skill on an AI_engine agent', async () => {
|
||||||
renderStudio();
|
renderStudio();
|
||||||
await screen.findAllByText('Stalled-rider response');
|
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 () => {
|
it('needs the agent id typed before switching autonomy on', async () => {
|
||||||
@@ -319,7 +339,7 @@ describe('Agent Studio page', () => {
|
|||||||
renderStudio();
|
renderStudio();
|
||||||
await screen.findAllByText('Stalled-rider response');
|
await screen.findAllByText('Stalled-rider response');
|
||||||
expect(screen.getByText('You can view the registry. Only an admin can change it.')).toBeInTheDocument();
|
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'));
|
fireEvent.click(screen.getByText('Details'));
|
||||||
expect(screen.getByRole('switch', { name: 'Stalled-rider response enabled' })).toBeDisabled();
|
expect(screen.getByRole('switch', { name: 'Stalled-rider response enabled' })).toBeDisabled();
|
||||||
@@ -331,12 +351,16 @@ describe('Agent Studio page', () => {
|
|||||||
renderStudio();
|
renderStudio();
|
||||||
await screen.findAllByText('Stalled-rider response');
|
await screen.findAllByText('Stalled-rider response');
|
||||||
fireEvent.click(screen.getAllByText('Tools')[0]);
|
fireEvent.click(screen.getAllByText('Tools')[0]);
|
||||||
const row = screen.getAllByText('nearby_milers').map((el) => el.closest('tr')).find(Boolean);
|
// Tools render as cards (a header button per tool): kind + safety badges.
|
||||||
expect(within(row).getByText('Read-only')).toBeInTheDocument();
|
// The name also appears in the agent navigator; the tool card is the button carrying its kind badge.
|
||||||
expect(within(row).getByText('No confirmation needed')).toBeInTheDocument();
|
const toolCard = (name) =>
|
||||||
const gated = screen.getAllByText('reassign_booking').map((el) => el.closest('tr')).find(Boolean);
|
screen.getAllByText(name).map((el) => el.closest('button')).find((btn) => btn && within(btn).queryByText(/^(Read|Write|Notify|General)$/));
|
||||||
expect(within(gated).getByText('Writes')).toBeInTheDocument();
|
const card = toolCard('nearby_milers');
|
||||||
expect(within(gated).getByText('Confirmation Required')).toBeInTheDocument();
|
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();
|
expect(screen.queryByText('Autonomous')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -406,7 +430,8 @@ describe('Agent Studio Test tab', () => {
|
|||||||
const openTest = async () => {
|
const openTest = async () => {
|
||||||
renderStudio();
|
renderStudio();
|
||||||
await screen.findAllByText('Stalled-rider response');
|
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 () => {
|
it('runs the prompt on the backend and shows its real trace', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user