update on the agents and registry side
This commit is contained in:
@@ -1,204 +1,469 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import {
|
||||
DEFAULT_SKILLS,
|
||||
DEFAULT_TOOLS,
|
||||
AGENTS,
|
||||
loadStoredSkills,
|
||||
saveStoredSkills,
|
||||
loadStoredTools,
|
||||
loadActiveAgentId,
|
||||
saveActiveAgentId,
|
||||
} from '@/pages/doormile/settings/agentStudio/agentRegistryData';
|
||||
// Stub lucide-react ESM exports for Jest
|
||||
jest.mock('lucide-react', () => {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (target, prop) => {
|
||||
if (prop === '__esModule') return true;
|
||||
return (props) => <span data-testid={`icon-${String(prop)}`} {...props} />;
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
// Stub framer-motion layout animations for jsdom
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...rest }) => <div {...rest}>{children}</div>,
|
||||
span: ({ children, ...rest }) => <span {...rest}>{children}</span>,
|
||||
},
|
||||
AnimatePresence: ({ children }) => <>{children}</>,
|
||||
/**
|
||||
* Settings → Skills & Tools, reading the backend agent registry.
|
||||
*
|
||||
* The real page, hooks and adapters run; only the HTTP functions are mocked,
|
||||
* with responses shaped exactly like /admin/ai/* (see doormile_backend
|
||||
* internal/ai/registry). What this proves: the page shows what the registry
|
||||
* says, sends the writes the registry expects, and refuses in the UI what the
|
||||
* server would refuse. What it does not: that the server accepts them — the
|
||||
* backend's own route and Postgres tests cover that side.
|
||||
*/
|
||||
|
||||
jest.mock('lucide-react', () =>
|
||||
new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />) })
|
||||
);
|
||||
|
||||
jest.mock('@/api/doormile', () => ({
|
||||
getAiAgents: jest.fn(),
|
||||
getAiSkills: jest.fn(),
|
||||
getAiTools: jest.fn(),
|
||||
updateAiSkill: jest.fn(),
|
||||
createAiSkill: jest.fn(),
|
||||
updateAiAgent: jest.fn(),
|
||||
getAiInsights: jest.fn(),
|
||||
getAiDecisions: jest.fn(),
|
||||
runAiPlayground: jest.fn(),
|
||||
}));
|
||||
|
||||
import SkillCard from '@/pages/doormile/settings/agentStudio/SkillCard';
|
||||
import SkillCardsGrid from '@/pages/doormile/settings/agentStudio/SkillCardsGrid';
|
||||
import ToolRegistryView from '@/pages/doormile/settings/agentStudio/ToolRegistryView';
|
||||
jest.mock('@/api/doormile/notify', () => ({
|
||||
OpenToast: jest.fn(),
|
||||
messageOf: (err, fallback = 'Something went wrong') => err?.response?.data?.message || err?.message || fallback,
|
||||
}));
|
||||
|
||||
describe('Agent Studio — Data Layer & Persistence', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
let mockUser = { role: 'admin' };
|
||||
jest.mock('@/lib/AuthContext', () => ({ useAuth: () => ({ user: mockUser }) }));
|
||||
|
||||
import * as api from '@/api/doormile';
|
||||
import AgentStudio from '@/pages/doormile/settings/agentStudio/AgentStudio';
|
||||
import { playgroundErrorMessage } from '@/pages/doormile/settings/agentStudio/AgentPlayground';
|
||||
import {
|
||||
buildStudioModel,
|
||||
canEditRegistry,
|
||||
schemaToParameters,
|
||||
toUiAgent,
|
||||
} from '@/pages/doormile/settings/agentStudio/registryAdapters';
|
||||
|
||||
// ── Registry fixtures (the backend's wire shape) ────────────────────────────
|
||||
|
||||
const AGENTS = [
|
||||
{
|
||||
agentid: 'EXCEPTION_AGENT', name: 'Exception', runtime: 'engine', classref: 'AI_engine/agents/exception_agent.py:106',
|
||||
purpose: 'Detects stalled riders.', wakeon: 'TRACKING miler.stalled', status: 'live', llmdecision: 'decide_stall_response',
|
||||
hasautonomygate: true, autonomous: false, model: '', skillcount: 1, toolcount: 2,
|
||||
},
|
||||
{
|
||||
agentid: 'HUB_AGENT', name: 'Hub', runtime: 'engine', classref: 'AI_engine/agents/hub_agent.py:35',
|
||||
purpose: 'Hub capacity over 8 fictional hubs.', wakeon: 'Direct task', status: 'simulation', llmdecision: '',
|
||||
hasautonomygate: false, autonomous: false, model: '', skillcount: 0, toolcount: 0,
|
||||
},
|
||||
{
|
||||
agentid: 'CONSOLE_OPS_AGENT', name: 'Console Ops Agent', runtime: 'console', classref: 'branch',
|
||||
purpose: 'Ops briefing.', wakeon: 'Console page load', status: 'unmerged', llmdecision: '',
|
||||
hasautonomygate: false, autonomous: false, model: '', skillcount: 1, toolcount: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const SKILLS = [
|
||||
{
|
||||
skillid: 'stall_response', agentid: 'EXCEPTION_AGENT', title: 'Stalled-rider response', category: 'rider_operations',
|
||||
description: 'Detect a stalled rider and respond.', sampleprompt: '', source: 'engine', enabled: true, version: 1,
|
||||
tools: ['reassign_booking', 'nearby_milers'],
|
||||
thresholds: { stallMinutes: 10 },
|
||||
thresholdsschema: [{ key: 'stallMinutes', label: 'Stall threshold', unit: 'min', default: 10, min: 5, max: 60, step: 5 }],
|
||||
},
|
||||
{
|
||||
skillid: 'skill_doorstep_stall', agentid: 'CONSOLE_OPS_AGENT', title: 'Doorstep Stall Rescuer', category: 'rider_operations',
|
||||
description: 'Flags riders stalled at a doorstep.', sampleprompt: '', source: 'console', enabled: true, version: 1,
|
||||
tools: ['lookup_order'], thresholds: {}, thresholdsschema: [],
|
||||
},
|
||||
];
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
toolname: 'reassign_booking', description: 'Reassign a booking whose rider has stalled.', kind: 'write',
|
||||
target: 'doormile_backend POST /internal/bookings/:id/reassign', implementedat: 'exception_agent.py:466', requiresconfirmation: true,
|
||||
inputschema: { type: 'object', properties: { booking_id: { type: 'integer', description: 'Booking to reassign' } }, required: ['booking_id'] },
|
||||
},
|
||||
{
|
||||
toolname: 'nearby_milers', description: 'Find riders near a point.', kind: 'read', target: 'Redis GEO', implementedat: 'dispatch_agent.py',
|
||||
requiresconfirmation: false, inputschema: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
toolname: 'lookup_order', description: 'Find an order.', kind: 'read', target: 'GET /admin/bookings', implementedat: 'tools.js',
|
||||
requiresconfirmation: false, inputschema: { type: 'object', properties: { identifier: { type: 'string' } }, required: ['identifier'] },
|
||||
},
|
||||
];
|
||||
|
||||
// /admin/ai/insights and /admin/ai/decisions, in their wire shape.
|
||||
const INSIGHTS = {
|
||||
days: 7,
|
||||
since: '2026-09-22T19:00:00Z',
|
||||
receiving: true,
|
||||
runs: {
|
||||
total: 42,
|
||||
failed: 3,
|
||||
peragent: [{ agentid: 'EXCEPTION_AGENT', runs: 42, failed: 3, avgdurationms: 412, lastrunat: '2026-09-29T18:40:00Z' }],
|
||||
},
|
||||
decisions: {
|
||||
total: 12,
|
||||
bytype: [{ decisiontype: 'miler_assignment', total: 12, outcomes: { success: 9, pending: 3 } }],
|
||||
},
|
||||
live: [{ agentid: 'EXCEPTION_AGENT', status: 'idle', currenttask: null, taskscompleted: 40, tasksfailed: 3, lastseenat: '2026-09-29T18:59:00Z' }],
|
||||
};
|
||||
|
||||
const DECISIONS = [
|
||||
{ id: 88, decisiontype: 'miler_assignment', bookingid: 501, decision: { miler_id: 8 }, reasoning: 'Nearest idle rider, 1.2 km', outcome: 'success', createdat: '2026-09-29T18:30:00Z' },
|
||||
];
|
||||
|
||||
const renderStudio = () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AgentStudio />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Pick an agent from the navigator's switcher.
|
||||
const switchTo = async (name) => {
|
||||
const header = screen.getAllByRole('button').find((b) => within(b).queryByText('AI_ENGINE AGENT') || within(b).queryByText('CONSOLE AGENT'));
|
||||
fireEvent.click(header);
|
||||
const menu = screen.getByText('Switch Agent Scope').parentElement;
|
||||
fireEvent.click(within(menu).getByText(name).closest('button'));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockUser = { role: 'admin' };
|
||||
jest.clearAllMocks();
|
||||
api.getAiAgents.mockResolvedValue(AGENTS);
|
||||
api.getAiSkills.mockResolvedValue(SKILLS);
|
||||
api.getAiTools.mockResolvedValue(TOOLS);
|
||||
api.updateAiSkill.mockResolvedValue({ success: true });
|
||||
api.createAiSkill.mockResolvedValue({ success: true });
|
||||
api.updateAiAgent.mockResolvedValue({ success: true });
|
||||
api.getAiInsights.mockResolvedValue(INSIGHTS);
|
||||
api.getAiDecisions.mockResolvedValue(DECISIONS);
|
||||
});
|
||||
|
||||
// ── Adapters ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('registry adapters', () => {
|
||||
it('maps an agent and gives every status a readable badge', () => {
|
||||
const a = toUiAgent(AGENTS[1]);
|
||||
expect(a).toMatchObject({ id: 'HUB_AGENT', runtime: 'engine', hasAutonomyGate: false });
|
||||
expect(a.statusMeta.label).toBe('Simulation');
|
||||
expect(toUiAgent({ ...AGENTS[1], status: 'something_new' }).statusMeta.label).toBe('Something_new');
|
||||
});
|
||||
|
||||
it('pre-seeds default agents and surfaces correctly', () => {
|
||||
expect(AGENTS.length).toBeGreaterThanOrEqual(2);
|
||||
expect(AGENTS.some((a) => a.name === 'MileTruth AI Agent')).toBe(true);
|
||||
expect(AGENTS.some((a) => a.name === 'MileTruth AI Dispatcher')).toBe(true);
|
||||
it('turns a JSON-schema into parameter rows, keeping required', () => {
|
||||
expect(schemaToParameters(TOOLS[0].inputschema)).toEqual([
|
||||
{ name: 'booking_id', type: 'integer', description: 'Booking to reassign', required: true },
|
||||
]);
|
||||
expect(schemaToParameters(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('pre-seeds default skills matching reference image', () => {
|
||||
expect(DEFAULT_SKILLS.some((s) => s.title === 'MileTruth Skill Management')).toBe(true);
|
||||
expect(DEFAULT_SKILLS.some((s) => s.title === 'Learning Analysis')).toBe(true);
|
||||
const learningSkill = DEFAULT_SKILLS.find((s) => s.title === 'Learning Analysis');
|
||||
expect(learningSkill.prompt).toBe('How is training progressing?');
|
||||
expect(learningSkill.category).toBe('WORKFORCE');
|
||||
it('names the skills that use each tool', () => {
|
||||
const { tools, skills } = buildStudioModel(AGENTS, SKILLS, TOOLS);
|
||||
expect(tools.find((t) => t.name === 'reassign_booking').parentSkill).toBe('Stalled-rider response');
|
||||
expect(skills.find((s) => s.id === 'stall_response').integration).toBe('Exception');
|
||||
});
|
||||
|
||||
it('persists modified skills to localStorage and restores them', () => {
|
||||
const customSkill = {
|
||||
id: 'custom-skill-1',
|
||||
title: 'Custom Automation Skill',
|
||||
category: 'LOGISTICS',
|
||||
scope: 'agent',
|
||||
enabled: true,
|
||||
description: 'Test custom capability',
|
||||
tools: ['create_single_order'],
|
||||
};
|
||||
|
||||
saveStoredSkills([customSkill]);
|
||||
const restored = loadStoredSkills();
|
||||
expect(restored.length).toBe(1);
|
||||
expect(restored[0].title).toBe('Custom Automation Skill');
|
||||
});
|
||||
|
||||
it('saves and loads active agent id', () => {
|
||||
saveActiveAgentId('miletruth-agent');
|
||||
expect(loadActiveAgentId()).toBe('miletruth-agent');
|
||||
it('lets only the admin role edit', () => {
|
||||
expect(canEditRegistry({ role: 'admin' })).toBe(true);
|
||||
expect(canEditRegistry({ role: 'Admin' })).toBe(true);
|
||||
expect(canEditRegistry({ role: 'manager' })).toBe(false);
|
||||
expect(canEditRegistry(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Studio — SkillCard Component', () => {
|
||||
const sampleSkill = {
|
||||
id: 'test-skill-1',
|
||||
title: 'MileTruth Skill Management',
|
||||
category: 'AUTHORING',
|
||||
scope: 'agent',
|
||||
integration: 'MileTruth AI',
|
||||
enabled: true,
|
||||
description: 'Create workforce skills, training and verification in MileTruth AI.',
|
||||
prompt: 'How is training progressing?',
|
||||
tools: ['open_add_training'],
|
||||
// ── The page ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Agent Studio page', () => {
|
||||
it('says on screen which agents the registry drives, and when', async () => {
|
||||
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 Claude 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/);
|
||||
});
|
||||
|
||||
it("shows only the selected agent's skills", async () => {
|
||||
renderStudio();
|
||||
expect(await screen.findAllByText('Stalled-rider response')).not.toHaveLength(0);
|
||||
expect(screen.queryAllByText('Doorstep Stall Rescuer')).toHaveLength(0);
|
||||
|
||||
await switchTo('Console Ops Agent');
|
||||
expect(await screen.findAllByText('Doorstep Stall Rescuer')).not.toHaveLength(0);
|
||||
expect(screen.queryAllByText('Stalled-rider response')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('marks a simulated agent as a simulation in the switcher', async () => {
|
||||
renderStudio();
|
||||
await screen.findAllByText('Stalled-rider response');
|
||||
const header = screen.getAllByRole('button').find((b) => within(b).queryByText('AI_ENGINE AGENT'));
|
||||
fireEvent.click(header);
|
||||
const hubRow = within(screen.getByText('Switch Agent Scope').parentElement).getByText('Hub').closest('button');
|
||||
expect(within(hubRow).getByText('Simulation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends a skill toggle to the registry', async () => {
|
||||
renderStudio();
|
||||
fireEvent.click(await screen.findByText('Details'));
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Stalled-rider response enabled' }));
|
||||
await waitFor(() => expect(api.updateAiSkill).toHaveBeenCalledWith('stall_response', { enabled: false }));
|
||||
});
|
||||
|
||||
it('saves only the thresholds that changed, and blocks an out-of-range value', async () => {
|
||||
renderStudio();
|
||||
fireEvent.click(await screen.findByText('Details'));
|
||||
const input = screen.getByRole('spinbutton', { name: 'Stall threshold' });
|
||||
const save = screen.getByRole('button', { name: 'Save thresholds' });
|
||||
|
||||
fireEvent.change(input, { target: { value: '99' } });
|
||||
expect(screen.getByText('Between 5 and 60')).toBeInTheDocument();
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
fireEvent.change(input, { target: { value: '12' } });
|
||||
expect(screen.getByText('In steps of 5')).toBeInTheDocument();
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
fireEvent.change(input, { target: { value: '15' } });
|
||||
expect(save).toBeEnabled();
|
||||
fireEvent.click(save);
|
||||
await waitFor(() => expect(api.updateAiSkill).toHaveBeenCalledWith('stall_response', { thresholds: { stallMinutes: 15 } }));
|
||||
});
|
||||
|
||||
it('shows the server’s refusal in the drawer', async () => {
|
||||
api.updateAiSkill.mockRejectedValue({ response: { data: { message: 'stallMinutes must be between 5 and 60' } } });
|
||||
renderStudio();
|
||||
fireEvent.click(await screen.findByText('Details'));
|
||||
fireEvent.change(screen.getByRole('spinbutton', { name: 'Stall threshold' }), { target: { value: '20' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save thresholds' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('stallMinutes must be between 5 and 60');
|
||||
});
|
||||
|
||||
it('registers a new skill on a console agent with the registry payload', async () => {
|
||||
renderStudio();
|
||||
await screen.findAllByText('Stalled-rider response');
|
||||
await switchTo('Console Ops Agent');
|
||||
fireEvent.click(await screen.findByText('New skill'));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Skill Title *'), { target: { value: 'Night shift watch' } });
|
||||
fireEvent.change(screen.getByLabelText('Description *'), { target: { value: 'Watch the night shift.' } });
|
||||
const dialog = screen.getByRole('dialog');
|
||||
fireEvent.click(within(dialog).getByText('lookup_order').closest('button'));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Add Skill' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.createAiSkill).toHaveBeenCalledWith({
|
||||
agentid: 'CONSOLE_OPS_AGENT',
|
||||
title: 'Night shift watch',
|
||||
category: 'logistics',
|
||||
description: 'Watch the night shift.',
|
||||
sampleprompt: '',
|
||||
tools: ['lookup_order'],
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('needs the agent id typed before switching autonomy on', async () => {
|
||||
renderStudio();
|
||||
await screen.findAllByText('Stalled-rider response');
|
||||
fireEvent.click(screen.getByText('Configure'));
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Autonomous' }));
|
||||
const save = screen.getByRole('button', { name: /Save changes/ });
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
const confirm = screen.getByText(/to confirm/).closest('label').querySelector('input');
|
||||
fireEvent.change(confirm, { target: { value: 'exception_agent' } });
|
||||
expect(save).toBeDisabled();
|
||||
fireEvent.change(confirm, { target: { value: 'EXCEPTION_AGENT' } });
|
||||
expect(save).toBeEnabled();
|
||||
|
||||
fireEvent.click(save);
|
||||
await waitFor(() =>
|
||||
expect(api.updateAiAgent).toHaveBeenCalledWith('EXCEPTION_AGENT', { autonomous: true, confirm: 'EXCEPTION_AGENT' })
|
||||
);
|
||||
});
|
||||
|
||||
it('offers no autonomy switch on an agent without a gate', async () => {
|
||||
renderStudio();
|
||||
await screen.findAllByText('Stalled-rider response');
|
||||
await switchTo('Hub');
|
||||
fireEvent.click(screen.getByText('Configure'));
|
||||
expect(screen.queryByRole('switch', { name: 'Autonomous' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/has no autonomy switch/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is read-only for a manager', async () => {
|
||||
mockUser = { role: 'manager' };
|
||||
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();
|
||||
|
||||
fireEvent.click(screen.getByText('Details'));
|
||||
expect(screen.getByRole('switch', { name: 'Stalled-rider response enabled' })).toBeDisabled();
|
||||
expect(screen.getByRole('spinbutton', { name: 'Stall threshold' })).toBeDisabled();
|
||||
expect(screen.queryByRole('button', { name: 'Save thresholds' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels read-only tools honestly, not as autonomous', async () => {
|
||||
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();
|
||||
expect(screen.queryByText('Autonomous')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows real run, failure and decision figures on Insights', async () => {
|
||||
renderStudio();
|
||||
await screen.findAllByText('Stalled-rider response');
|
||||
fireEvent.click(screen.getByText('Insights'));
|
||||
|
||||
const runs = (await screen.findByText('Agent runs')).parentElement;
|
||||
expect(runs).toHaveTextContent('42');
|
||||
expect(screen.getByText('7% of runs').parentElement).toHaveTextContent('Failed3');
|
||||
expect(screen.getByText('Agents reporting').parentElement).toHaveTextContent('1 / 2');
|
||||
|
||||
const row = screen.getByRole('table', { name: 'Runs per AI_engine agent' });
|
||||
const exception = within(row).getByText('Exception').closest('tr');
|
||||
expect(exception).toHaveTextContent('idle');
|
||||
expect(exception).toHaveTextContent('412 ms');
|
||||
// The silent agent is shown as silent with zero runs, not left out.
|
||||
expect(within(row).getByText('Hub').closest('tr')).toHaveTextContent('silent');
|
||||
|
||||
expect(screen.getByText('success · 9')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Nearest idle rider, 1.2 km')).toBeInTheDocument();
|
||||
expect(screen.queryByText('99.4%')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('asks the insights endpoint for the chosen window', async () => {
|
||||
renderStudio();
|
||||
await screen.findAllByText('Stalled-rider response');
|
||||
fireEvent.click(screen.getByText('Insights'));
|
||||
await screen.findByText('Agent runs');
|
||||
expect(api.getAiInsights).toHaveBeenLastCalledWith(7);
|
||||
fireEvent.click(screen.getByRole('button', { name: '30 days' }));
|
||||
await waitFor(() => expect(api.getAiInsights).toHaveBeenLastCalledWith(30));
|
||||
});
|
||||
|
||||
it('says plainly when no telemetry is being received', async () => {
|
||||
api.getAiInsights.mockResolvedValue({ ...INSIGHTS, receiving: false, runs: { total: 0, failed: 0, peragent: [] }, live: [] });
|
||||
renderStudio();
|
||||
await screen.findAllByText('Stalled-rider response');
|
||||
fireEvent.click(screen.getByText('Insights'));
|
||||
expect(await screen.findByText(/Not receiving agent telemetry/)).toBeInTheDocument();
|
||||
expect(screen.getByText('No runs can be recorded until telemetry is received.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says why when the registry cannot be read', async () => {
|
||||
api.getAiAgents.mockRejectedValue({ response: { data: { message: 'available to Doormile staff only' } } });
|
||||
renderStudio();
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert).toHaveTextContent('Could not load the agent registry');
|
||||
expect(alert).toHaveTextContent('available to Doormile staff only');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Test tab (Phase 6): a real backend run, not a simulation ───────────────
|
||||
|
||||
const TRACE = {
|
||||
agentid: 'EXCEPTION_AGENT', skillid: '', model: 'claude-opus-5-5', turns: 2, inputtokens: 1200, outputtokens: 300,
|
||||
ms: 4200, stopreason: 'end_turn', final: 'I would reassign it.',
|
||||
steps: [
|
||||
{ kind: 'tool', tool: 'nearby_milers', toolkind: 'read', input: { lat: 11, lon: 77 }, outcome: 'executed', result: { count: 2 }, ms: 12 },
|
||||
{ kind: 'tool', tool: 'reassign_booking', toolkind: 'write', input: { booking_id: 5 }, outcome: 'proposed', result: { executed: false }, ms: 0 },
|
||||
{ kind: 'text', text: 'I would reassign it.', ms: 900 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('Agent Studio Test tab', () => {
|
||||
const openTest = async () => {
|
||||
renderStudio();
|
||||
await screen.findAllByText('Stalled-rider response');
|
||||
fireEvent.click(screen.getByText('Test'));
|
||||
};
|
||||
|
||||
it('renders title, description, category, and prompt trigger bubble', () => {
|
||||
render(<SkillCard skill={sampleSkill} onTest={() => {}} onDetails={() => {}} />);
|
||||
it('runs the prompt on the backend and shows its real trace', async () => {
|
||||
api.runAiPlayground.mockResolvedValue(TRACE);
|
||||
await openTest();
|
||||
fireEvent.change(screen.getByLabelText('Playground prompt'), { target: { value: 'booking 5 is stuck' } });
|
||||
fireEvent.click(screen.getByText('Run'));
|
||||
|
||||
expect(screen.getByText('MileTruth Skill Management')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Create workforce skills, training and verification in MileTruth AI.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('AUTHORING')).toBeInTheDocument();
|
||||
expect(screen.getByText('"How is training progressing?"')).toBeInTheDocument();
|
||||
expect(screen.getByText('MileTruth AI')).toBeInTheDocument();
|
||||
await screen.findByText('I would reassign it.');
|
||||
expect(api.runAiPlayground).toHaveBeenCalledWith(
|
||||
{ agentid: 'EXCEPTION_AGENT', skillid: undefined, prompt: 'booking 5 is stuck' },
|
||||
expect.anything()
|
||||
);
|
||||
const tools = screen.getAllByTestId('trace-tool');
|
||||
expect(tools[0]).toHaveTextContent('nearby_milers()');
|
||||
expect(tools[0]).toHaveTextContent('executed');
|
||||
expect(tools[1]).toHaveTextContent('reassign_booking()');
|
||||
expect(tools[1]).toHaveTextContent('proposal only — not executed');
|
||||
expect(screen.getByTestId('trace-meta')).toHaveTextContent('2 turns · 1200 in / 300 out tokens · 4.2 s');
|
||||
expect(screen.getByText('claude-opus-5-5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onTest when Test in Playground is clicked', () => {
|
||||
const handleTest = jest.fn();
|
||||
render(<SkillCard skill={sampleSkill} onTest={handleTest} onDetails={() => {}} />);
|
||||
|
||||
const testBtn = screen.getByText('Test in Playground');
|
||||
fireEvent.click(testBtn);
|
||||
expect(handleTest).toHaveBeenCalledWith(sampleSkill);
|
||||
it('sends the chosen skill', async () => {
|
||||
api.runAiPlayground.mockResolvedValue(TRACE);
|
||||
await openTest();
|
||||
fireEvent.change(screen.getByLabelText('Skill:'), { target: { value: 'stall_response' } });
|
||||
fireEvent.change(screen.getByLabelText('Playground prompt'), { target: { value: 'x' } });
|
||||
fireEvent.click(screen.getByText('Run'));
|
||||
await waitFor(() => expect(api.runAiPlayground).toHaveBeenCalled());
|
||||
expect(api.runAiPlayground.mock.calls[0][0].skillid).toBe('stall_response');
|
||||
});
|
||||
|
||||
it('fires onDetails when Details button is clicked', () => {
|
||||
const handleDetails = jest.fn();
|
||||
render(<SkillCard skill={sampleSkill} onTest={() => {}} onDetails={handleDetails} />);
|
||||
it('says plainly when the server has no Claude client', async () => {
|
||||
api.runAiPlayground.mockRejectedValue({ response: { status: 503, data: { code: 'PLAYGROUND_NOT_CONFIGURED' } } });
|
||||
await openTest();
|
||||
fireEvent.change(screen.getByLabelText('Playground prompt'), { target: { value: 'hi' } });
|
||||
fireEvent.click(screen.getByText('Run'));
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert).toHaveTextContent('not switched on for this server yet');
|
||||
expect(alert).toHaveTextContent('Nothing was run');
|
||||
});
|
||||
|
||||
const detailsBtn = screen.getByText('Details');
|
||||
fireEvent.click(detailsBtn);
|
||||
expect(handleDetails).toHaveBeenCalledWith(sampleSkill);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Studio — SkillCardsGrid Component', () => {
|
||||
it('renders skill cards and filters by search query', () => {
|
||||
render(
|
||||
<SkillCardsGrid
|
||||
skills={DEFAULT_SKILLS}
|
||||
activeAgent={AGENTS[0]}
|
||||
onTestSkill={() => {}}
|
||||
onOpenDetails={() => {}}
|
||||
onNewSkill={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('MileTruth Skill Management')).toBeInTheDocument();
|
||||
expect(screen.getByText('Learning Analysis')).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search skills, capabilities or pages...');
|
||||
fireEvent.change(searchInput, { target: { value: 'Learning' } });
|
||||
|
||||
expect(screen.queryByText('MileTruth Skill Management')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Learning Analysis')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters by category pill selection', () => {
|
||||
render(
|
||||
<SkillCardsGrid
|
||||
skills={DEFAULT_SKILLS}
|
||||
activeAgent={AGENTS[0]}
|
||||
onTestSkill={() => {}}
|
||||
onOpenDetails={() => {}}
|
||||
onNewSkill={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Click 'Authoring' category pill
|
||||
const authoringPill = screen.getByText('Authoring');
|
||||
fireEvent.click(authoringPill);
|
||||
|
||||
expect(screen.getByText('MileTruth Skill Management')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Learning Analysis')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('triggers onNewSkill when clicking New skill button', () => {
|
||||
const handleNewSkill = jest.fn();
|
||||
render(
|
||||
<SkillCardsGrid
|
||||
skills={DEFAULT_SKILLS}
|
||||
activeAgent={AGENTS[0]}
|
||||
onTestSkill={() => {}}
|
||||
onOpenDetails={() => {}}
|
||||
onNewSkill={handleNewSkill}
|
||||
/>
|
||||
);
|
||||
|
||||
const newSkillBtn = screen.getByText('New skill');
|
||||
fireEvent.click(newSkillBtn);
|
||||
expect(handleNewSkill).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Studio — ToolRegistryView Component', () => {
|
||||
it('renders tool registry table and displays argument schemas', () => {
|
||||
render(<ToolRegistryView tools={DEFAULT_TOOLS} />);
|
||||
|
||||
expect(screen.getByText('open_add_skill_training')).toBeInTheDocument();
|
||||
expect(screen.getByText('create_single_order')).toBeInTheDocument();
|
||||
expect(screen.getByText('rebalance_riders')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters tool list by search input', () => {
|
||||
render(<ToolRegistryView tools={DEFAULT_TOOLS} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
'Filter tools by function name, category or description...'
|
||||
);
|
||||
fireEvent.change(searchInput, { target: { value: 'rebalance' } });
|
||||
|
||||
expect(screen.getByText('rebalance_riders')).toBeInTheDocument();
|
||||
expect(screen.queryByText('open_add_skill_training')).not.toBeInTheDocument();
|
||||
it('does not let a non-admin run it', async () => {
|
||||
mockUser = { role: 'manager' };
|
||||
await openTest();
|
||||
expect(screen.getByLabelText('Playground prompt')).toBeDisabled();
|
||||
expect(screen.getByText('Run').closest('button')).toBeDisabled();
|
||||
expect(screen.getByText('Only an admin can run the Test playground.')).toBeInTheDocument();
|
||||
expect(api.runAiPlayground).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps server errors to operator messages', () => {
|
||||
expect(playgroundErrorMessage({ response: { status: 429, data: { message: 'Playground limit reached: 10 runs per 10 minutes.' } } }))
|
||||
.toMatch(/10 runs per 10 minutes/);
|
||||
expect(playgroundErrorMessage({ response: { status: 403, data: {} } })).toMatch(/Only an admin/);
|
||||
expect(playgroundErrorMessage({ response: { status: 502, data: { message: 'The Claude request failed; nothing was changed.' } } }))
|
||||
.toMatch(/nothing was changed/);
|
||||
expect(playgroundErrorMessage(new Error('Network Error'))).toMatch(/Nothing was changed/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user