165 lines
6.6 KiB
JavaScript
165 lines
6.6 KiB
JavaScript
// One engine, one set of thresholds.
|
|
//
|
|
// The chat panel and the Exceptions banner used to run different code for the
|
|
// same five rules, with the thresholds hardcoded in one and tunable in the
|
|
// other. They disagreed out of the box — a doorstep stall was 25 minutes in the
|
|
// chat panel and 20 on the banner — and moving a slider in Agent Studio changed
|
|
// one surface and not the other.
|
|
//
|
|
// These tests exist to stop that coming back. They are less about any single
|
|
// rule than about the property that both surfaces are the same engine.
|
|
|
|
import { buildBriefing } from '@/lib/assistant/agent/briefing';
|
|
import { AgentFactory } from '@/lib/assistant/agent/AgentFactory';
|
|
import { SkillRegistry } from '@/lib/assistant/skills/SkillRegistry';
|
|
import { toAgentRows } from '@/lib/assistant/agent/normalise';
|
|
import * as signals from '@/lib/assistant/agent/signals';
|
|
|
|
const NOW = '2026-08-29 14:00:00';
|
|
|
|
const rawBooking = (over = {}) => ({
|
|
bookingid: 1,
|
|
bookingno: 'DM-BK-001',
|
|
status: 'Pickup_Scheduled',
|
|
createdat: '2026-08-29 13:50:00',
|
|
serviceoptions: [],
|
|
...over
|
|
});
|
|
|
|
// A board with something wrong on it: a breach, a doorstep stall and aging work.
|
|
const busyScan = () => ({
|
|
truncated: false,
|
|
rows: [
|
|
rawBooking({
|
|
bookingid: 1,
|
|
bookingno: 'DM-BK-001',
|
|
serviceoptions: [{ estimateddeliveryat: '2026-08-29 12:30:00' }]
|
|
}),
|
|
rawBooking({
|
|
bookingid: 2,
|
|
bookingno: 'DM-BK-002',
|
|
createdat: '2026-08-29 10:00:00',
|
|
reachedat: '2026-08-29 13:15:00',
|
|
assignedmileruserid: 8,
|
|
milername: 'Kumar'
|
|
}),
|
|
rawBooking({ bookingid: 3, bookingno: 'DM-BK-003', status: 'Pending_Pickup', createdat: '2026-08-29 11:30:00' })
|
|
]
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// Thresholds persist to localStorage, so one test's override would otherwise
|
|
// leak into the next.
|
|
SkillRegistry.clearRegistryConfig();
|
|
});
|
|
|
|
describe('the chat panel and the banner are the same engine', () => {
|
|
it('produces identical findings for identical rows', () => {
|
|
const scan = busyScan();
|
|
|
|
// What the chat panel shows.
|
|
const fromBriefing = buildBriefing(scan, NOW).findings;
|
|
|
|
// What the Exceptions banner shows — its exact call.
|
|
const fromBanner = AgentFactory.synthesizeDefaultAgent().evaluateTelemetry(toAgentRows(scan.rows), NOW);
|
|
|
|
expect(fromBriefing.map((f) => f.id)).toEqual(fromBanner.map((f) => f.id));
|
|
expect(fromBriefing.map((f) => f.count)).toEqual(fromBanner.map((f) => f.count));
|
|
});
|
|
|
|
// The regression that started this: the chat panel knew five rules while the
|
|
// registry defined twelve findings across eight skills, so asking "what needs
|
|
// attention" could never surface cash exposure, hub congestion or a rider on
|
|
// a dying battery.
|
|
it('the chat panel sees every enabled skill, not a private subset', () => {
|
|
// Enable all eight through the registry, as an admin could. By default three
|
|
// are off (no data source yet — see their `dataGap`), so "more than five"
|
|
// no longer holds out of the box; the point is that whatever the registry
|
|
// enables, the chat panel runs.
|
|
SkillRegistry.applyRegistryConfig(
|
|
SkillRegistry.getAllSkills().map((s) => ({ skillid: s.id, enabled: true, thresholds: {} }))
|
|
);
|
|
const enabled = SkillRegistry.getActiveSkills();
|
|
expect(enabled).toHaveLength(8);
|
|
|
|
const agent = AgentFactory.synthesizeDefaultAgent();
|
|
expect(agent.activeSkillCount).toBe(8);
|
|
});
|
|
|
|
it('ships the three skills with no data source switched off', () => {
|
|
SkillRegistry.clearRegistryConfig();
|
|
const off = SkillRegistry.getAllSkills().filter((s) => !s.enabled).map((s) => s.id).sort();
|
|
expect(off).toEqual(['skill_cash_exposure', 'skill_high_value_cod', 'skill_rider_battery_safety']);
|
|
off.forEach((id) => expect(SkillRegistry.getSkill(id).dataGap).toMatch(/\S/));
|
|
});
|
|
});
|
|
|
|
describe('no hardcoded thresholds survive', () => {
|
|
// signals.js is now a shared-primitives module. If a THRESHOLDS object or a
|
|
// rule reappears here, the two-engine bug is back.
|
|
it('signals.js exports no rules and no threshold table', () => {
|
|
expect(signals.THRESHOLDS).toBeUndefined();
|
|
expect(signals.diagnose).toBeUndefined();
|
|
expect(signals.breachedSla).toBeUndefined();
|
|
expect(signals.atRiskSla).toBeUndefined();
|
|
expect(signals.unassignedAging).toBeUndefined();
|
|
expect(signals.stalledAtDoor).toBeUndefined();
|
|
expect(signals.saturatedRiders).toBeUndefined();
|
|
});
|
|
|
|
it('signals.js still provides the shared primitives', () => {
|
|
expect(typeof signals.wallClockNow).toBe('function');
|
|
expect(typeof signals.ALL_CLEAR).toBe('string');
|
|
});
|
|
|
|
// Every threshold an operator can see must be declared by a skill, with the
|
|
// metadata Agent Studio needs to render a slider for it.
|
|
it('every skill threshold is declared with tunable bounds', () => {
|
|
SkillRegistry.getAllSkills().forEach((skill) => {
|
|
Object.entries(skill.thresholds || {}).forEach(([key, cfg]) => {
|
|
expect(typeof cfg.value).toBe('number');
|
|
expect(typeof cfg.min).toBe('number');
|
|
expect(typeof cfg.max).toBe('number');
|
|
expect(cfg.min).toBeLessThanOrEqual(cfg.value);
|
|
expect(cfg.value).toBeLessThanOrEqual(cfg.max);
|
|
// A threshold with no label cannot be rendered as a slider, so it would
|
|
// silently become un-tunable — hardcoded by omission.
|
|
if (!cfg.label) {
|
|
throw new Error(`${skill.id}.${key} has no label; Agent Studio cannot render a slider for it`);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('tuning a threshold moves both surfaces', () => {
|
|
// The property that was broken: an override applied in Agent Studio reached
|
|
// the banner and never reached the chat panel.
|
|
it('an override changes what the chat panel reports', () => {
|
|
const scan = {
|
|
truncated: false,
|
|
rows: [
|
|
rawBooking({
|
|
bookingid: 2,
|
|
createdat: '2026-08-29 10:00:00',
|
|
// Stalled for 22 minutes: inside the old hardcoded 25, outside the
|
|
// skill's default 20. Exactly the parcel the two engines disagreed on.
|
|
reachedat: '2026-08-29 13:38:00',
|
|
assignedmileruserid: 8,
|
|
milername: 'Kumar'
|
|
})
|
|
]
|
|
};
|
|
|
|
const before = buildBriefing(scan, NOW).findings.map((f) => f.id);
|
|
expect(before).toContain('stalled_at_door');
|
|
|
|
// Raise the stall timeout past 22 minutes; the finding must disappear from
|
|
// the chat panel too, not just the banner.
|
|
SkillRegistry.applyRegistryConfig([{ skillid: 'skill_doorstep_stall', enabled: true, thresholds: { arrivedStalledMin: 45 } }]);
|
|
|
|
const after = buildBriefing(scan, NOW).findings.map((f) => f.id);
|
|
expect(after).not.toContain('stalled_at_door');
|
|
});
|
|
});
|