update on the agents and registry side

This commit is contained in:
2026-09-30 12:03:13 +05:30
parent 92e8da2842
commit 53199127ba
73 changed files with 7268 additions and 1134 deletions

724
docs/agent-platform-plan.md Normal file
View File

@@ -0,0 +1,724 @@
# Doormile Agent Platform — Plan & Agent Registry
Status: **Phases 0–4 done (uncommitted, not deployed), Phase 5 next** · Written 2026-09-29 · Scope: `krow_talent_app` (the
Doormile console), `doormile_backend` (Go API), `AI_engine` (Python agent swarm).
This is the document `src/pages/doormile/settings/Settings.jsx` has been pointing
at ("Phase 6 of docs/agent-platform-plan.md"). That file never existed until now,
so the phase numbers below replace the ones the comment assumed.
---
## 1. Where things actually stand (verified 2026-09-29)
### Console — `krow_talent_app`
- **Settings → Skills & Tools (Agent Studio) is a mock.** `agentRegistryData.js`
holds 2 agents, 3 surfaces, 4 skills and 6 tools. Everything is saved to
`localStorage` and nowhere else. The Test tab (`AgentPlayground.jsx:37`) is a
`setTimeout` that always replies "succeeded with 100% confidence". Configure and
Insights use hard-coded figures ("99.4%", "540 calls") and a model
`claude-3-5-sonnet` that nothing reads.
- **Three of the six mock tools are Krow leftovers:** `open_add_skill_training`,
`open_add_training` and `query_learning_analytics` (workforce training, not
logistics). Delete them; don't migrate them.
- **The Agents page** (`/doormile/agents`) runs on a static snapshot in
`src/lib/agentNetwork.js`, dated 16–20 Sep 2026. It is honest about being a
snapshot, but its test (`tests/integration/agentsPage.test.jsx`) is stale: that
is the 1 failing suite and 9 failing tests out of 45 suites.
- **Unmerged branch `feat/agentic-ops-layer`** (dharaneesh, 2026-09-01) is the only
real client-side agent work:
- `SkillRegistry` with 8 rule-based skills: SlaGuardian, DoorstepStall,
FleetBalancer, HighValueCod, RiderBatterySafety, HubCongestion, LateDispatch,
CashExposure.
- `tools.js` with 5 tools, following the rule "read-only tools execute, mutating
tools return a Proposal".
- AgentFactory, the ops briefing, proposal executors with a verify pass, and 20+
test files.
- It is **29 commits behind main and conflicts in 10 files**. Its skill config is
also stored in `localStorage`.
- **The Home assistant** runs on the regex catalogue `src/lib/assistant/intents.js`
(109 KB) and has no concept of skills.
- **Other state:** lint shows 35 auto-fixable errors, and `Deliveries.jsx` has an
uncommitted cosmetic change (a tidy-up of the batch filter).
### Backend — `doormile_backend`
- Build, vet and test all pass (Go 1.26.4). There are 233 routes; CLAUDE.md is
stale on counts, retry, routing, PIN auth and SMS (see its drift list).
- **Nothing for an agent registry exists yet.** `internal/ai/` is an empty,
untracked directory.
- **What does exist:**
- `models.AgentDecision` (table `agent_decisions`, pgvector 1536-dim — the
384-vs-1536 question is still open).
- Internal routes, all behind `X-Internal-Key`:
- `POST /internal/agent-decisions`
- `GET /internal/agent-decisions/similar`
- `PATCH /internal/agent-decisions/:id/outcome`
- `/internal/express/{riders,bookings,assign}`
- **Admin authorization is flat.** Roles 1, 3 and 4 get identical access, and no
handler checks the role further. Many admin write handlers have no tenant guard.
So any registry write endpoint must add its own role check (see §5).
### Engine — `AI_engine`
- **It has no registry to read from.** Agents are hard-coded classes in
`main.py:55-70`. `core/tool_registry.py` is an in-memory dict holding 3 tools,
and none of them load in production. The two "skills" in `core/skills/` are
stubs that nothing imports.
- **The agent process has no HTTP API.** The Command Center (FastAPI, :8600) is a
separate, unauthenticated NATS tap.
- **Runs, escalations and decisions live in memory only.** They are capped at 500
and lost on restart.
- **Tests:** 72 pass, but only with pytest installed by hand; `requirements.txt`
lacks pytest.
**Conclusion:** the registry must be **built**, not wired up. It belongs in
`doormile_backend` (Postgres). The console reads it, and the engine reads it.
Neither of them owns it.
---
## 2. The Agent Registry — what gets seeded
This is the real inventory. Every row here becomes a seed row in Phase 1.
"Status" is what the code does today, not what the docs claim.
### 2.1 Agents
| id | Class / file | Purpose | Trigger | Writes to system | LLM | Status |
|---|---|---|---|---|---|---|
| `JARVIS` | `MasterAgent` · `core/agent.py:196` | Orchestrator, escalation inbox | `logistics.direct.JARVIS` | none | — | Partial. The inbox works; `orchestrate_order` sends the wrong payload key |
| `DISPATCH_AGENT` | `agents/dispatch_agent.py:72` | Watches assignment outcomes, flags coverage gaps | JetStream `booking.assigned`, `booking.assignment_failed` | Alerts; customer notify only when autonomous | `decide_assignment_failure` | **Production-grade.** Idle until `assignment_failed` is confirmed as published (see §7) |
| `EXCEPTION_AGENT` | `agents/exception_agent.py:106` | Stalled-rider detection and response | TRACKING `miler.location.updated`, `miler.stalled`, plus a 60 s DB sweep | `POST /internal/bookings/:id/reassign` (needs autonomy flag and confidence ≥ 0.7), `/internal/notify` | `decide_stall_response` | **Production-grade** (the stall path only) |
| `EXPRESS_DISPATCH_AGENT` | `agents/express_dispatch_agent.py:80` | Tenant batch assign plus road sequencing | JetStream `express.dispatch_requested` | `POST /internal/express/assign` | — (greedy) | Implemented. Autonomy defaults to `true` in code and `false` in compose |
| `CUSTOMER_AGENT` | `agents/customer_agent.py:50` | Customer notifications | Direct tasks, only from an autonomous Dispatch | `/internal/notify` | — | Implemented, rarely reached |
| `ORDER_AGENT` | `agents/order_agent.py:15` | Order intake and validation | Direct tasks (no sender in production) | `/admin/crmbooking` — a **dead path, no auth header** | — | Broken. `update_status` crashes (`order_agent.py:182`) |
| `HUB_AGENT` | `agents/hub_agent.py:35` | Hub capacity | Direct tasks | none | — | **Simulation** (8 fictional hubs) |
| `FLEET_AGENT` | `agents/fleet_agent.py:34` | Vehicles | Direct tasks | none | — | **Simulation** (19 fake vehicles) |
| `ROUTE_OPTIMIZER` | `agents/route_optimizer_agent.py:41` | Routing | Direct tasks | none | — | **Simulation** (haversine only) |
| `CONSOLE_ASSISTANT` | `src/lib/assistant/*` (console) | Home chat: orders, bulk, assign, repeat | Operator prompt | Via proposals the operator confirms | RAG sidecar (off) | Live, regex-based |
| `CONSOLE_OPS_AGENT` | `feat/agentic-ops-layer` | Ops briefing plus 8 monitoring skills | Page load / poll | Via proposals the operator confirms | — (rules) | **Unmerged** |
The registry shows `status` as one of `live | partial | simulation | broken |
unmerged | retired`. **The console must display this badge.** A simulation agent
that looks live is the exact failure the Agents page comment warns about.
### 2.2 Tools (the real capabilities, not the stubs)
`kind` is `read`, `write` or `notify`. Every `write` or `notify` tool has
`requires_confirmation = true` unless its agent is explicitly autonomous.
| name | Owner agent(s) | Kind | Target | Today lives at |
|---|---|---|---|---|
| `reassign_booking` | EXCEPTION | write | Go `/internal/bookings/:id/reassign` | `exception_agent.py:466` |
| `notify_customer` | EXCEPTION, CUSTOMER | notify | Go `/internal/notify` | `exception_agent.py:476`, `customer_agent.py:349` |
| `list_express_bookings` | EXPRESS | read | Go `/internal/express/bookings` | `express_dispatch_agent.py:351` |
| `list_express_riders` | EXPRESS | read | Go `/internal/express/riders` | `express_dispatch_agent.py:342` |
| `assign_express_batch` | EXPRESS | write | Go `/internal/express/assign` | `express_dispatch_agent.py:222` |
| `sequence_stops` | EXPRESS | read (compute) | `routes.workolik.com /optimization/doormile/sequence` | `express_dispatch_agent.py:308` |
| `get_booking_cache` | CUSTOMER | read | Go `/bookings/cache/:id` | `customer_agent.py:243` |
| `nearby_milers` | DISPATCH, EXCEPTION | read | Redis GEO `milers:locations` | `dispatch_agent.py:170-240` |
| `publish_miler_stalled` | EXCEPTION | write (event) | NATS `miler.stalled` | `exception_agent.py:377` |
| `decide_stall_response` | EXCEPTION | read (LLM) | Claude | `core/llm.py:157` |
| `decide_assignment_failure` | DISPATCH | read (LLM) | Claude | `core/llm.py:227` |
| `record_agent_decision` | all | write | Go `/internal/agent-decisions` | Go route exists |
| `diagnose_operations` | CONSOLE_OPS | read | Console queries | branch `tools.js` |
| `lookup_order` | CONSOLE_OPS | read | `/admin/bookings` | branch `tools.js` |
| `lookup_miler` | CONSOLE_OPS | read | `/admin/milers` | branch `tools.js` |
| `propose_miler_reassignment` | CONSOLE_OPS | write → proposal | `/admin/bookings/:id/assign-miler` | branch `tools.js` |
| `simulate_pricing_quote` | CONSOLE_OPS, CONSOLE_ASSISTANT | read | `/admin/pricing/simulate` | branch `tools.js` |
| `create_single_order` | CONSOLE_ASSISTANT | write → proposal | `/admin/expressbooking` | current mock |
| `rebalance_riders` | CONSOLE_OPS | write → proposal | `/hub/bookings/batch-assign` | current mock (no backing code) |
**Not seeded:**
- `ask_question`, `order_intake_skill`, `repeat_run_skill` (engine stubs that are
never loaded).
- The 3 Krow training tools.
- ORDER_AGENT's `crmbooking` calls: that route was renamed to `expressbooking`, so
those calls hit nothing.
### 2.3 Skills
A skill is a named behaviour that belongs to one agent and uses one or more tools.
It has a switch (`enabled`) and tunable `thresholds` (JSON, validated against a
per-skill schema).
| id | Agent | Tools | Source |
|---|---|---|---|
| `stall_response` | EXCEPTION | nearby_milers, decide_stall_response, reassign_booking, notify_customer | engine |
| `assignment_failure_triage` | DISPATCH | nearby_milers, decide_assignment_failure | engine |
| `express_batch_dispatch` | EXPRESS | list_express_*, assign_express_batch, sequence_stops | engine |
| `sla_guardian` · `doorstep_stall` · `fleet_balancer` · `high_value_cod` · `rider_battery_safety` · `hub_congestion` · `late_dispatch` · `cash_exposure` | CONSOLE_OPS | branch `tools.js` set | branch (thresholds move from localStorage to the registry) |
| `order_intake_auto_schedule` | CONSOLE_ASSISTANT | create_single_order, simulate_pricing_quote | current mock, real behaviour in `orderFlow.js` |
| `dispatch_rebalance` | CONSOLE_OPS | rebalance_riders | current mock. **Keep disabled** until a backing endpoint exists |
### 2.4 Surfaces
`/doormile/home` (assistant), `/doormile/control-x` (dispatch board),
`/doormile/agents` (status board), and the ops banner (branch
`AgentOperationsBanner`). `/doormile/dispatch` is only a redirect to Control X, so
it is not a separate surface.
---
## 3. Schema (Phase 1) — ⚠ additive schema change, review before deploy
New tables in `doormile_backend`, added through GORM AutoMigrate. They are
additive only and touch no existing table.
```
ai_agents id text PK, name, class_ref, runtime ('engine'|'console'),
purpose, trigger jsonb, status, autonomous bool,
model text NULL, created_at, updated_at
ai_tools name text PK, description, kind ('read'|'write'|'notify'),
target text, input_schema jsonb, requires_confirmation bool,
enabled bool, created_at, updated_at
ai_skills id text PK, agent_id FK→ai_agents, title, category,
description, sample_prompt, enabled bool,
thresholds jsonb, thresholds_schema jsonb,
version int, updated_by int NULL, updated_at
ai_skill_tools skill_id FK, tool_name FK, PRIMARY KEY (skill_id, tool_name)
ai_registry_audit id bigserial, entity, entity_id, field, old jsonb, new jsonb,
changed_by int, changed_at
```
Rules:
- **Tools and agents are code-defined.** The console can toggle `enabled` and
`autonomous`, but it cannot invent a tool. A tool with no implementation is a lie
on screen. "New skill" in the console therefore picks from existing tools only.
- **Every write goes into `ai_registry_audit` in the same transaction.**
- **Seeding is idempotent** (upsert by id) and lives in `migrations/`, not
`scratch/`.
- **Never store a secret.** Env var *names* only; the engine's `.env` stays out of
the registry.
---
## 4. API contract (Phase 1)
All handlers go in `controllers/aiRegistryController.go` and the logic in
`internal/ai/registry`. Responses use `utils.OK` / `utils.List`, following the
console conventions.
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/admin/ai/agents` | admin (1,3,4), Doormile staff only | Includes a `status` badge and skill/tool counts |
| GET | `/admin/ai/agents/:id` | same | Agent plus its skills and tools |
| GET | `/admin/ai/skills` | same | `?agent=` filter |
| GET | `/admin/ai/tools` | same | `?kind=` filter |
| PATCH | `/admin/ai/skills/:id` | **roleid 1 only** | Allowed fields: `enabled`, `thresholds` (validated against schema). Bumps `version` and writes an audit row |
| POST | `/admin/ai/skills` | roleid 1 only | New skill from existing tools only |
| PATCH | `/admin/ai/agents/:id` | roleid 1 only | Allowed fields: `autonomous`, `model`. **Extra confirm in UI** — this changes what an agent does without a human |
| GET | `/admin/ai/audit` | admin | Registry change history |
| GET | `/admin/ai/decisions` | admin | Paged `agent_decisions` (Phase 4) |
| GET | `/internal/ai/registry` | `X-Internal-Key` | The engine pulls its config from here (Phase 5). It sends an ETag so the engine can poll cheaply |
"Doormile staff only" means `consoleTenantID == 0`. A tenant or client login gets
403. It does **not** get an empty list, because an empty list would hide a
misconfiguration.
---
## 5. Phases
Each phase ships on its own and leaves the system working. Nothing is committed or
pushed without an explicit ask.
### Phase 0 — Prerequisites and hygiene (small, do first)
1. **Rotate secrets and untrack them.**
- `doormile_backend`: `.env` and `doormile-abee7-*.json` are tracked; the
`.gitignore` line `#.env` is commented out.
- `AI_engine`: `.env` is tracked.
- `config/config.go:60-71` has hard-coded fallback secrets. Fail at boot
instead.
- This is a **user action** (rotation needs the providers' consoles). I can do
the untracking and ignore rules.
2. **Decide `feat/agentic-ops-layer`** (see §6, decision A).
3. **Console:** fix the stale `agentsPage.test.jsx`, run `lint:fix` for the 35
unused imports, and delete the 3 Krow training tools from
`agentRegistryData.js`.
4. **Add a PREVIEW banner** on the Agent Studio tab itself. Today only a code
comment says so; Agents shows a snapshot label, Agent Studio shows nothing.
5. **Backend:** remove the broken `.claude/skills/*` symlink stubs, `.agents/`,
`skills.md` and `skills-lock.json`. The global plugin already provides these
skills.
**Done when:** tests are green, lint is clean, and no secret is in `git ls-files`.
**Phase 0 result (2026-09-29, uncommitted):**
- Console: lint clean; **46/46 suites, 1192 tests pass**.
- Agent Studio: 4 Krow tools and 2 Krow skills removed (the plan said 3 tools;
`open_miletruth_ai` was a fourth). `dispatch_rebalance` ships disabled.
Storage keys moved to `_v3`. An on-screen Preview note was added.
- Agents page: this was not just a stale test. The 24–25 Sep rebuild presented
a simulation as live. Per decision, the design was kept and labelled:
- a `Snapshot · 16–20 Sep 2026` stamp;
- "Sample Activity — Simulation · not live data";
- no pulsing dot;
- status counts taken from `networkStats()`;
- "Autonomy gates on: 0 / 3", where the old "0 / 8" implied 8 gates.
The tests were rewritten, keeping the honesty checks.
- Backend:
- **Reverted 2026-09-29 at Suriya's request:** these are back in git
exactly as at HEAD:
- the untracking of `.env` and the service-account key (the key is
still committed, so rotation still stands);
- the `.gitignore` edit;
- the removal of `skills.md`, `skills-lock.json`, `.agents/` and
`.claude/skills/`.
AI_engine's `.env` is tracked again too. Do not redo any of this without
asking.
- `main.go` now refuses to boot when `ENV=production` and `JWT_SECRET_KEY`,
`DB_PASSWORD` or `NATS_PASSWORD` is unset.
- build, vet and test are green.
- AI_engine: `.env` was untracked (it was already in `.gitignore`).
- **Still yours:**
- Rotate every secret that was committed. Git history still holds the
values.
- **Before the next backend deploy,** confirm that production sets all
three secrets. If it has been running on a fallback, the new check stops
it from starting.
### Phase 1 — Registry in the backend
- Add the §3 tables, the idempotent seed from §2, and the §4 read endpoints plus
PATCH/POST with the role check and audit trail.
- **Tests:** a seed-idempotency test, a role test (roles 3 and 4 get 403 on PATCH;
a tenant login gets 403 on GET), a threshold-schema validation test, and an audit
test.
- **Done when:** `go build/vet/test` is green and `curl` against a staging DB
returns the §2 inventory.
**Phase 1 result (2026-09-29, uncommitted, NOT deployed, no real DB touched):**
- **Tables.** They follow the codebase's naming, not the names in §3:
`aiagents`, `aitools`, `aiskills`, `aiskilltools`, `airegistryaudit`.
The columns are as in §3, with a few changes:
- The agent's trigger column is named `wakeon`.
- `hasautonomygate` is new. Autonomy can only be set on Dispatch,
Exception and Express.
- `source` (engine/console/custom) is on skills.
- There is no `enabled` flag on tools.
- **Code.**
- `internal/ai/registry`: seed, rules, store.
- `controllers/aiRegistryController.go`
- `middlewares/staff_only.go` (`DoormileStaffOnly`)
- Routes are under `/admin/ai/*` and `/internal/ai/registry`, as in §4.
- The seed runs in `migrations.Migrate`. It logs a failure and does not
stop the boot.
- **Seed.** 11 agents, 26 tools and 15 skills.
- Four skills were added to §2.3 so that every tool belongs to a skill:
`customer_notifications`, `ops_briefing`, and the 8 branch tools
folded into their skills.
- The console ops skills keep the branch ids and threshold keys, so
Phase 3 is a 1:1 mapping.
- `record_agent_decision` was dropped. It is a log the Go side writes, not
a capability.
- **Rules enforced server-side.**
- Switching autonomy ON needs `confirm` set to the agent id.
- Model ids must match `claude-*`, and only engine agents have one.
- Thresholds are validated for range and step, and a patch is all-or-nothing.
- Custom skills can be added to console agents only, and only from existing
tools.
- A patch that changes nothing does not bump the version or write an audit
row.
- **Tests.**
- 26 unit tests and 6 HTTP gate tests always run.
- 10 Postgres integration tests and 1 HTTP end-to-end test run only when
`REGISTRY_TEST_DSN` is set. They need a throwaway database; each package
uses its own schema.
- All of them passed against a disposable `postgres:16-alpine` container.
- **Bug the Postgres run caught.** With `enabled` tagged `default:true`, gorm
dropped `false` from the INSERT, so `dispatch_rebalance` came up
**enabled**. Fixed by removing the column default.
- **Not yet proven.** The migration has not run against the real database;
that happens on your next deploy. It is 5 new tables and touches nothing
existing.
### Phase 2 — Console Agent Studio reads the registry
- Replace `agentRegistryData.js` and its `localStorage` with React Query hooks
(`useAiAgents`, `useAiSkills`, `useAiTools`) in `src/lib/doormileHooks.js`, and
add the endpoints to `src/api/doormile/endpoints.js`.
- Keep the existing components; only the data source changes. Also show the status
badge, the tool `kind`, and a confirmation marker.
- The skill toggle and "New skill" become real PATCH and POST calls. Add a
`loading`/`error` state; remove the optimistic toast that claims success before
the server answers.
- Configure tab: model and autonomy from the registry. The temperature slider is
**dropped**, because nothing reads it.
- Insights and Test stay behind the PREVIEW banner until Phases 4 and 6.
- Rewrite `tests/integration/agentStudio.test.jsx` against mocked hooks.
- **Done when:** a toggle made in one browser shows in another, and survives a
reload.
**Phase 2 result (2026-09-29, uncommitted, NOT deployed):**
- **Console.**
- Endpoints were added to `api/doormile/endpoints.js`: `getAiAgents`,
`getAiSkills`, `getAiTools`, `updateAiSkill`, `createAiSkill`,
`updateAiAgent` and `getAiRegistryAudit`.
- Hooks were added in `lib/doormileHooks.js`: `useAiAgents`, `useAiSkills`,
`useAiTools` and three mutations. They share one `['doormile','ai']` key.
- The adapter layer is `agentStudio/registryAdapters.js`, a set of pure
functions.
- `agentRegistryData.js` now keeps only the surfaces list and the
selected-agent preference.
- **Changes on screen.**
- Skills are filtered to the selected agent; before, every agent's skills
showed.
- Agent status badges appear in the switcher.
- The skill drawer has a real enable toggle, a threshold editor (range and
step checked in the browser, then on the server), and shows source and
version.
- The tool table shows the kind, "Used by", the system each tool touches
and where it is implemented. It no longer calls a read-only tool
"Autonomous".
- Configure shows the agent's record, a model picker (engine agents only)
and an autonomy switch (gated agents only) with a typed confirmation.
The fake temperature slider and GPT/DeepSeek list are gone.
- Insights shows "No run data yet" instead of invented figures.
- "New skill" is disabled on AI_engine agents and for anyone who is not an
admin.
- With no saved choice the page opens on the first agent that has skills,
not on JARVIS, which has none.
- **Backend addition.** `airegistryaudit.changedbyemail` records the token's
email. The end-to-end run showed every audit row with `changedby = 0`: an
admin login without an appusers row carries user id 0.
- **Tests.**
- Console: 18 Agent Studio tests (adapters plus the page with only HTTP
mocked). The full suite is 46/46 suites and 1193 tests, and lint is clean.
- Backend: everything is green with the database attached, including the
parallel run that clashed before per-package schemas.
- **End-to-end, in a real browser, fully local.**
- Setup: a throwaway Postgres; the backend running with no `.env` and every
host pinned to localhost; a second console on :5174; throwaway admin and
manager logins.
- Checked in the database: seed counts, the toggle, the threshold change,
autonomy on (with confirmation) and off, and custom skill creation, each
with its audit row.
- Checked in the browser: the manager view is read-only.
- Checked by direct API call: a manager's write gets 403.
- Everything was removed afterwards: container, image, scripts, test
credentials and temporary launch entries.
### Phase 3 — Land the ops-layer skills on main
- Port the 8 rule-based skills, `tools.js`, the proposal executors and the banner
from the branch onto current main. Resolve the 10 conflicts; the branch's
`Deliveries.jsx` edits collide with today's uncommitted change.
- `SkillRegistry` then reads `enabled`/`thresholds` from `/admin/ai/skills` and
falls back to code defaults when offline. It stops reading `localStorage`.
- **Keep the branch invariant:** write tools return Proposals, and a human
confirms.
- **Done when:** the branch's 20+ test files pass on main, and a threshold changed
in Agent Studio changes the banner's output.
**Phase 3 result (2026-09-29, uncommitted, NOT deployed):**
- **Ported, as unstaged file copies (no merge).**
- The 8 skill definitions.
- `agent/{AgentFactory,signals,normalise,briefing,actions}.js`.
- `SlaRemediationCard`.
- `AgentOperationsBanner`, mounted on the Exceptions page.
- The `opsBriefing` chat intent in `lib/assistant/intents.js`.
- The "Needs attention" chip on the Exceptions context.
- 11 branch test suites.
- **Settings.** `SkillRegistry` now takes enabled and thresholds from
`/admin/ai/skills` through `useSkillRegistrySync`, mounted once in
`AdminLayout`. Nothing is kept in localStorage. It falls back to code
defaults, and says so on the banner, when the registry cannot be read.
- **Not ported.**
- `tools.js`: nothing imported it.
- `AgentStudioModal`: a second, localStorage-only settings UI. "Configure
skills" goes to Settings → Skills & Tools instead.
- `AgentDecisionDrawer` and the `/internal/agent-decisions` endpoints: they
return 403 from the console.
- The AI-panel "Autonomous Fleet Agent" card, and the branch's cosmetic
edits.
- **Defects found and fixed while porting.**
1. `OpenToast('success', msg)` has its arguments swapped; the signature is
`(message, variant)`. Every successful action would have shown a red
error toast reading "success". The branch's tests asserted the same
wrong order.
2. The `assignMiler` executor posted to `/hub/bookings/batch-assign`, which
is behind `HubStaffAuth` (role 6 only). Every console click would 403.
It is now review-only, with the reason in `actions.js`.
3. **Three skills could never fire.** High-Value COD, Cash Exposure and
Battery Safety read payment and battery fields that `/admin/bookings`
rows do not carry. They would report a false all-clear. They ship **off**
(`dataGap` in code, `Enabled: false` plus the reason in the seed).
4. The chat trigger was narrowed. It no longer claims "late/delayed orders"
or "operations summary", which would have replaced existing answers.
Routing tests pin both directions.
5. `SlaRemediationCard.test.jsx` could not have run: no `lucide-react` stub.
- **Registry seed updated to match main.**
- `CONSOLE_OPS_AGENT` is `live`.
- Paths point at main.
- The console tools are now the proposal verbs: `scan_bookings`,
`notify_riders` (the only executor), `assign_riders` (review-only), and
five review-only actions, each labelled REVIEW ONLY.
- New backend tests guard the no-data skills and the review-only labels.
- **Tests.**
- Console: 64/64 suites, 1308 tests; lint is clean and the build is green.
- Backend: build, vet and all tests are green. The Postgres-gated tests were
not re-run; the seed change is data-only and unit-tested.
- `Deliveries.jsx` was untouched; the uncommitted change there is still only
Suriya's.
- **Open items.**
- Feeding the three off skills needs `/admin/bookings` to include payment
amounts and mode, and the rider's battery. That is a backend
response-shape change.
- "Assign riders" needs an admin batch-assign route. `useBatchAssignBookings`
has the same 403 problem, but nothing calls it.
- `lib/assistant/CLAUDE.md` is now stale: it says proactive alerts were
"not started" and that the `components/assistant` copies are live, but
`AIPanel` imports `lib/assistant`.
### Phase 4 — Persist decisions and runs (makes Insights real)
- A Go NATS consumer on `telemetry.task` plus the engine's LLM decisions, written to
`agent_decisions`, plus a new `ai_agent_runs` table (⚠ schema change).
- `/admin/ai/decisions` and run stats feed the Insights tab and the Agents page,
replacing the `agentNetwork.js` snapshot.
- **Resolve before relying on vector search:** the `context_embedding`
1536-vs-384 dimension question.
**Phase 4 result (2026-09-29, uncommitted, NOT deployed):**
- **Finding.** Nothing in doormile_backend or AI_engine writes
`agent_decisions`. routemate (external) returns an `agent_decision_id` from
`/decide-assignment`, so it presumably writes through
`POST /internal/agent-decisions`. Whether production has rows is unverified.
AI_engine's two LLM decisions (stall, assignment-failure) are **not**
persisted anywhere; they appear only in logs.
- **Backend.**
- One new table, `aiagentruns`: append-only, unique on (agentid, taskid),
pruned after 30 days.
- `internal/ai/telemetry` queue-subscribes (`doormile-backend-telemetry`) to
`telemetry.task`, and writes runs in batches (2 s / 200). The NATS callback
never blocks: a full buffer drops the event, counts it and logs it.
- `telemetry.agent` heartbeats go to Redis (`ai:agent:state:<id>`, 5-minute
TTL), not Postgres.
- New endpoints, Doormile staff only: `GET /admin/ai/insights?days=1..30`
(runs, failures and average time per agent; decisions by type and outcome;
live state; a `receiving` flag) and `GET /admin/ai/decisions`
(keyset-paged; the `context` column is excluded because it holds rider
data).
- Windows use the backend clock (`utils.DBNow`). The engine's naive
timestamp is stored for display only.
- **Console.** Insights shows those figures with a 24 h / 7 d / 30 d window.
Silent agents appear as "silent" with zero runs rather than being left out.
When `receiving` is false, the page says telemetry is not received rather
than showing "0 runs" as if nothing happened.
- **Tests.**
- Backend: 11 telemetry unit tests, a Postgres-gated suite, and the new
routes in the gate tests.
- All 14 Postgres-gated tests (Phases 1 and 4) pass against a throwaway
`postgres:16-alpine`. This includes the parallel `go test ./...` run.
- Console: 64/64 suites, 1310 tests, lint clean, build green.
- **Live end-to-end run (2026-09-29, local, throwaway; all removed after).**
- Setup: throwaway Postgres and NATS; the real backend binary with no
`.env`; engine-shaped telemetry published over raw NATS.
- Six messages produced three rows. The redelivered task was ignored by the
unique index. The malformed event was dropped. The heartbeat was not
written to Postgres.
- `/admin/ai/insights` returned the right totals, failures and averages.
`/admin/ai/decisions` paged correctly and did not leak `context`.
- The Insights tab rendered the same figures with correct IST times.
- **Bugs the live run caught, fixed.**
1. The recorder stamped `utils.DBNow()` into a **timestamptz** column, which
AutoMigrate creates for new tables. A run received at 20:57 IST read back
as 02:27 the next day. It now uses `time.Now()`, and the window cutoffs
do too. `TestRecorderStampsARealInstant` fails with "5h30m off" if
DBNow comes back.
**Wider note:** `utils.DBNow` is only correct for the legacy
timestamp-WITHOUT-zone columns. Any table AutoMigrate creates fresh is
timestamptz, so audit other new tables before using DBNow in them.
2. Two Phase 1 Postgres fixtures still used `lookup_order`/`lookup_miler`,
which Phase 3 removed from the seed.
3. The Skills & Tools note still said the console skills were "not merged".
It now says they run on these settings, and that AI_engine does not
read them yet.
- **Deploy prerequisites.**
1. The backend's `NATS_URL` must point at the same NATS server AI_engine
publishes to (`NATS_HOST`/`NATS_PORT` there). Otherwise Insights shows
"Not receiving agent telemetry".
2. The Agents page still uses the 16–20 Sep snapshot. Moving it onto
`/admin/ai/insights` is a follow-up; it is dharaneesh's page.
3. To see AI_engine's LLM decisions in Insights, the engine would have to
POST them to `/internal/agent-decisions`. That is engine work (Phase 5).
### Phase 5 — Engine reads the registry
- The engine polls `GET /internal/ai/registry` (ETag, around 30 s) and applies
`enabled`, `autonomous`, `model` and thresholds without a restart. Today the
autonomy flags are read once at import.
- **Fix before any agent is shown as live:**
- `order_agent.py:182` (the enum does not exist)
- the JARVIS→ORDER payload key (`agent.py:279` vs `order_agent.py:144`)
- the DISPATCH→HUB id mismatch (`dispatch_agent.py:268` vs `hub_agent.py:151`)
- `release_vehicle_for_cancel` has no handler (`exception_agent.py:692`)
- messages without a `task_type` are silently dropped
- ORDER_AGENT still calls the renamed `crmbooking` route, with no auth header
- Mark HUB, FLEET and ROUTE_OPTIMIZER as `simulation` in the seed, or retire them.
- Add `pytest` and `pytest-asyncio` to `requirements.txt`.
#### Phase 5 — result (2026-09-29, uncommitted, not deployed)
- **Registry client** (`AI_engine/core/registry.py`).
- Polls `/internal/ai/registry` every `REGISTRY_POLL_SECONDS` (30 by default)
with `If-None-Match`. It is started from `main.py --production`.
- Precedence: once the registry has loaded, its value applies. Before that,
or if it never loads, the old env default applies.
- The last good copy survives 401/5xx/timeouts, so autonomy cannot flip
mid-shift.
- With no `INTERNAL_API_KEY`, it logs once and the engine runs on env
defaults.
- **What each agent now reads.**
| Agent | Skill gate | Autonomy | Other settings |
|---|---|---|---|
| Exception | `stall_response` | `EXCEPTION_AGENT` | `stallMinutes`, `reassignConfidence`, model |
| Dispatch | `assignment_failure_triage` | `DISPATCH_AGENT` | `realertEvery`, model |
| Express Dispatch | `express_batch_dispatch` | `EXPRESS_DISPATCH_AGENT` | `maxPerRider`, `maxRadiusKm`, `loadPenaltyKm` |
| Customer | `customer_notifications` | — | — |
A disabled skill means the agent logs the event and does nothing.
- **Model override.**
- `core/llm.request_params(model)` uses the agent's pinned model, or
`LLM_MODEL` when none is pinned.
- For a Haiku pin, thinking and effort are left out, because Haiku rejects
them (400).
- The console picker offers Opus 5.5, Sonnet 5.5 and Opus 4.8, plus "Engine
default". Haiku is left out because it is too weak for these decisions.
- **Decisions logged.**
- Stall and assignment-failure decisions are POSTed to
`/internal/agent-decisions` as `{decision_type, booking_id, context:{facts, model}, decision:{action, confidence}, reasoning}`.
- The post is fire-and-forget, so a slow backend never delays the reaction.
- These decisions now appear in Insights → Latest decisions.
- **Behaviour fix.** When the LLM is down and the agent is *not* autonomous,
it now escalates to a human. Before, it reassigned regardless of the
autonomy flag.
- **Message bugs fixed.**
1. `ORDER_STATUS_UPDATE` was added to the enum.
2. JARVIS→ORDER now sends `order_id`.
3. DISPATCH no longer forwards to HUB `prepare_receiving`. It sent a booking
id to a fictional-hub simulation.
4. FLEET handles `release_vehicle_for_cancel`, finding the vehicle by order
id.
5. CUSTOMER records `ORDER_CANCELLED` and `NOTIFICATION_SENT` instead of
dropping them. These come from simulated records, so no real customer
message is sent.
6. ORDER_AGENT refuses its backend calls and logs why. The seed now says it
is not connected. It stays `broken`.
- **Simulation agents.** HUB, FLEET and ROUTE_OPTIMIZER stay seeded as
`simulation`, and the Studio note says so.
- **Tests.**
- AI_engine: 95 unittest tests. 28 are new, in
`tests/test_registry_phase5.py`, all with no network. Three
`test_dispatch_agent` mocks were updated for the `model` argument.
- The only failures are the two modules that import pytest, and they failed
before this work. `pytest` and `pytest-asyncio` are now in
`requirements.txt` but are **not installed** in the venv.
- Console: Agent Studio suite green. Backend: `internal/ai/...` green.
- **Deploy prerequisites.**
1. The engine needs `GO_API_BASE_URL` and `INTERNAL_API_KEY`, the same key
the backend checks.
2. Before deploying, check the registry's current `enabled` and
`autonomous` values. On deploy they replace the env flags
(`AUTONOMOUS_REASSIGN` and the others).
### Phase 6 — Real Test playground
- Replace the `setTimeout` simulation with a backend endpoint that runs one prompt
through Claude tool-use, using the tools from the selected skill (their
`input_schema` from the registry).
- **Read tools execute. Write tools return a Proposal only** — the playground never
mutates production.
- Show the real trace: tool calls, arguments, results, latency and tokens. The
model comes from the registry.
#### Phase 6 — result (2026-09-29, uncommitted, not deployed)
- **Decisions:** use the official Go SDK, and redact personal data before
anything reaches Claude. **The SDK download was blocked by this machine's
permission check.** Everything else is built behind a `Model` interface.
The one missing piece is the ~80-line adapter from `playground.Request` to
`anthropic.MessageNewParams`. It needs
`go get github.com/anthropics/anthropic-sdk-go`, run or approved by a person.
Until then, `controllers.PlaygroundModel` is nil. The endpoint answers 503
`PLAYGROUND_NOT_CONFIGURED` and the Test tab says so; it never pretends to
run.
- **Backend** (`internal/ai/playground`, `controllers/aiPlaygroundController.go`).
- `POST /admin/ai/playground/run {agentid, skillid?, prompt}`, for Doormile
staff with roleid 1 only. The limit is 10 runs per user per 10 minutes,
prompts are at most 2000 characters, and a run times out after 120 s.
- The loop runs at most 6 turns with max_tokens 4096. The model comes from
the agent's registry pin, or `claude-opus-5-5` if none. The skill's tools
come from the registry, with `input_schema` taken from `inputschema`.
- Tool outcomes:
| Tool | Outcome |
|---|---|
| read, served by the backend (`get_booking_cache`, `scan_bookings`, `nearby_milers`) | **executed**, 5 s timeout |
| read, engine-only or external (`decide_*`, `sequence_stops`, `simulate_pricing_quote`, `list_express_*`) | **unavailable** |
| write / notify / event | **proposed**. Never executed; the model gets `{executed:false, proposal}` |
| not in the selected skill | **rejected** |
- **Redaction:**
- Executors select named non-personal columns only. There is no address,
name, phone or notes column, and coordinates are rounded to 2 dp.
- `Redact` then masks personal keys (name, phone, address, email, note,
reason, …) plus any email or Indian mobile number found in a string.
- Results are capped at 16 KB.
- The seed now gives `nearby_milers` (lat, lon, radius_km) and
`scan_bookings` (status, limit) real input schemas.
- **Console:**
- The Test tab calls the endpoint and shows the server's trace: each tool
call with its input, outcome, result and ms, plus the model, turns,
tokens and time.
- It has a skill picker, and "Test in Playground" preselects that skill.
Non-admins can't run it.
- 503, 429 and 403 errors each show a plain message.
- The setTimeout simulation is gone.
- **Tests:**
- Backend: 11 unit tests. They cover Prepare, every outcome, the turn limit,
model errors, truncation and redaction, including that dates are not
masked.
- A Postgres-gated test proves that personal columns in the table are never
selected. It passed on a throwaway `postgres:16-alpine`, which was removed
afterwards.
- Route tests: the gates, the 503 with no client, and bad input refused
before the model is called.
- Console: 5 new tests. Now 64/64 suites and 1315 tests; build green.
- **To switch it on:**
1. Add the SDK and the adapter.
2. Set `ANTHROPIC_API_KEY` on the backend.
3. Wire `controllers.PlaygroundModel` in `main.go`.
- **Earlier notes:**
1. **Official Go SDK or raw HTTP.** The Go SDK
(`github.com/anthropics/anthropic-sdk-go`) is not in the module cache, so
using it means a module download plus a new `go.mod` dependency.
2. **Data leaving for the Claude API.** The read tools (`scan_bookings`,
`get_booking_cache`, `nearby_milers`, `list_express_*`) return live
customer names, phones and addresses. Running them in the playground sends
that data to Anthropic. The options are: allow it; redact PII before it is
sent; or run against fixtures only.
- The backend also needs `ANTHROPIC_API_KEY`. Without it, the endpoint
returns 503 and the Test tab stays a labelled simulation.
---
## 6. Decisions
**Ratified 2026-09-29:** A = port, C = roleid 1 only. The Agents page is kept
and labelled. The secret check fails at boot in production only. B, D and E
follow the recommendations below unless changed.
| # | Decision | My recommendation |
|---|---|---|
| A | `feat/agentic-ops-layer`: merge or port? | **Port** the skills, tools and executors onto current main (Phase 3). Don't merge a 29-commit-stale branch with 10 conflicts. First confirm with dharaneesh that nothing newer exists elsewhere |
| B | Where does the registry live? | **`doormile_backend`/Postgres.** The engine has no API or persistence, and the console must not be the source of truth |
| C | Who may edit skills and autonomy? | **roleid 1 only.** Roles 3 and 4 read only. Today 1, 3 and 4 are identical everywhere, so this needs an explicit check |
| D | May the console toggle agent autonomy (auto-reassign riders, auto-notify customers)? | Yes, but only with a typed confirmation and an audit row. It stays **off** by default, matching compose |
| E | Delete or relabel the simulation agents (HUB, FLEET, ROUTE_OPTIMIZER)? | Seed them as `simulation`. Delete later if nobody objects |
---
## 7. Open questions (need checking, not guessing)
- **Is `booking.assignment_failed` published?** The engine's handoff doc says Go
doesn't publish it yet. Backend CLAUDE.md §4 names `publishAssignmentFailed`, and
the new retry window says `assignment_failed` fires after the first round. Check
which stream and subject it actually uses against what DISPATCH_AGENT binds to.
- **Which model is live?** The engine defaults to `LLM_MODEL=claude-opus-4-8`; the
console mock shows `claude-3-5-sonnet`. The registry's `model` field should hold
the id that is actually deployed. Set per agent, a cheaper model such as Haiku is
enough for the stall and assignment decisions.
- **Registry write safety:** admin handlers today are not tenant-guarded for
global data (pricing, hubs, app users). The registry endpoints must not copy that
pattern.

View File

@@ -28,6 +28,7 @@ const Deliveries = lazy(() => import('@/pages/doormile/deliveries/Deliveries'));
const Tenants = lazy(() => import('@/pages/doormile/clients/Tenants'));
const CreateClient = lazy(() => import('@/pages/doormile/clients/CreateClient'));
const ClientOnboarding = lazy(() => import('@/pages/doormile/clients/ClientOnboarding'));
const CreateCustomer = lazy(() => import('@/pages/doormile/clients/CreateCustomer'));
const Pricing = lazy(() => import('@/pages/doormile/pricing/Pricing'));
@@ -124,6 +125,8 @@ const AuthenticatedApp = () => {
<Route path="tenants" element={<Tenants />} />
<Route path="clients/create" element={<CreateClient />} />
{/* Owner-only (admin@doormile.com); the page and the server both check. */}
<Route path="clients/onboard" element={<ClientOnboarding />} />
<Route path="customer/create" element={<CreateCustomer />} />
<Route path="pricing" element={<Pricing />} />

View File

@@ -166,6 +166,31 @@ export const createAdminTenant = async (data) => {
return response.data;
};
/**
* Onboard a client: the tenant, its console login (doormile_auth) and its
* appusers row, in one server transaction. Only the onboarding owner login
* (admin@doormile.com) may call it; anyone else gets 403.
* @param {{ companyname: string, contactname: string, email: string, phone: string,
* password: string, applocationid: number, requiredeliveryotp?: boolean }} data
* @returns {Promise<{ success: boolean, data: { tenant: object, login: object } }>}
*/
export const onboardClient = async (data) => {
const response = await doormileAxios.post('/admin/clients/onboard', data);
return response.data;
};
/** Operating cities a new client can be placed in (applocations; owner login only). */
export const getOnboardingCities = async () => {
const response = await doormileAxios.get('/admin/clients/cities');
return response.data.data;
};
/** Clients that have a console login, newest first (owner login only). */
export const getOnboardedClients = async () => {
const response = await doormileAxios.get('/admin/clients/onboarded');
return response.data.data;
};
export const getAdminTenant = async (id) => {
const response = await doormileAxios.get(`/admin/tenants/${id}`);
return response.data.data;
@@ -728,3 +753,80 @@ export const deleteCarrierPricing = async (id) => {
const response = await doormileAxios.delete(`/admin/carrier-pricing/${id}`);
return response.data;
};
// ==============================|| AI agent registry ||============================== //
// /admin/ai/* — Doormile staff only (a partner-tenant login gets 403). Reads
// are open to admin/manager/executive; every write is admin-only and audited
// server-side. See docs/agent-platform-plan.md.
export const getAiAgents = async () => {
const response = await doormileAxios.get('/admin/ai/agents');
return response.data.data;
};
export const getAiSkills = async () => {
const response = await doormileAxios.get('/admin/ai/skills');
return response.data.data;
};
export const getAiTools = async () => {
const response = await doormileAxios.get('/admin/ai/tools');
return response.data.data;
};
export const getAiRegistryAudit = async (limit) => {
const response = await doormileAxios.get(`/admin/ai/audit${buildQuery({ limit })}`);
return response.data.data;
};
/** @param {string} id @param {{ enabled?: boolean, thresholds?: Record<string, number> }} patch */
export const updateAiSkill = async (id, patch) => {
const response = await doormileAxios.patch(`/admin/ai/skills/${encodeURIComponent(id)}`, patch);
return response.data;
};
/** @param {{ agentid: string, title: string, category?: string, description?: string, sampleprompt?: string, tools: string[] }} skill */
export const createAiSkill = async (skill) => {
const response = await doormileAxios.post('/admin/ai/skills', skill);
return response.data;
};
/**
* Autonomy ON needs `confirm` set to the agent id — the server refuses it
* otherwise, so a stray click cannot let an agent act without a human.
* @param {string} id @param {{ autonomous?: boolean, model?: string, confirm?: string }} patch
*/
export const updateAiAgent = async (id, patch) => {
const response = await doormileAxios.patch(`/admin/ai/agents/${encodeURIComponent(id)}`, patch);
return response.data;
};
/**
* What the agents did over the last `days` (1–30): runs and failures per
* AI_engine agent, decisions by type/outcome, live heartbeat. `receiving` is
* false when the backend is not subscribed to AI_engine telemetry.
* @param {number} days
*/
export const getAiInsights = async (days = 7) => {
const response = await doormileAxios.get(`/admin/ai/insights${buildQuery({ days })}`);
return response.data.data;
};
/**
* Agent Studio's Test tab: one prompt through Claude with a skill's tools.
* Read tools run with personal data redacted; write tools come back as
* proposals and are never executed. Admin only. 503 (code
* PLAYGROUND_NOT_CONFIGURED) when the server has no Claude client; 429 when
* the per-user limit is reached.
* @param {{ agentid: string, skillid?: string, prompt: string }} body
*/
export const runAiPlayground = async (body) => {
const response = await doormileAxios.post('/admin/ai/playground/run', body);
return response.data.data;
};
/** Recent agent decisions, newest first. `before` is the last id of the previous page. */
export const getAiDecisions = async ({ type, before, limit } = {}) => {
const response = await doormileAxios.get(`/admin/ai/decisions${buildQuery({ type, before, limit })}`);
return response.data.data;
};

View File

@@ -12,7 +12,8 @@ import {
LuUserPlus,
LuPackagePlus,
LuListPlus,
LuRepeat
LuRepeat,
LuShieldAlert
} from 'react-icons/lu';
// ==============================|| Doormile AI — page context ||============================== //
@@ -45,6 +46,7 @@ import {
// suggestion with no entry here simply shows its full text.
// ---------------------------------------------------------------------------
export const CHIP_LABELS = {
'What needs attention right now?': 'Needs attention',
"Give me today's operations summary": "Today's summary",
'How many orders today?': 'Orders today',
'Which orders are delayed?': 'Delayed orders',
@@ -171,6 +173,9 @@ const TRIPSHEETS = {
const EXCEPTIONS = {
label: 'Exceptions',
suggestions: [
// The ops briefing (intents.js opsBriefing) — the same rules as the early
// warnings banner on this page, asked for in words.
{ icon: LuShieldAlert, text: 'What needs attention right now?' },
{ icon: LuCircleDot, text: 'How many open exceptions?' },
{ icon: LuTimerOff, text: 'Which orders are delayed?' }
],

View File

@@ -0,0 +1,196 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Sparkles, SlidersHorizontal, RefreshCw } from 'lucide-react';
import SlaRemediationCard from './SlaRemediationCard';
import { scanBookings } from '@/lib/assistant/scan';
import { toAgentRows } from '@/lib/assistant/agent/normalise';
import { AgentFactory } from '@/lib/assistant/agent/AgentFactory';
import { SkillRegistry } from '@/lib/assistant/skills/SkillRegistry';
import { wallClockNow } from '@/lib/assistant/agent/signals';
import { executeProposal, canExecuteProposal } from '@/lib/assistant/agent/actions';
import { Button } from '@/components/ui/button';
/**
* AgentOperationsBanner
*
* Early warnings from the console's monitoring skills. It evaluates the live
* booking scan against every skill ENABLED IN THE AGENT REGISTRY, with the
* registry's thresholds (Settings → Skills & Tools). Nothing here acts on its
* own: a finding's action runs only when an operator clicks it, and actions
* with no working endpoint render "Review only".
*
* Ported from feat/agentic-ops-layer (docs/agent-platform-plan.md, Phase 3).
* The branch opened a localStorage-only settings modal from here; that modal
* was not ported — "Configure skills" goes to the registry page instead.
*
* Props:
* - category: string | string[] (e.g. 'sla_management', 'rider_safety', 'fleet_optimization', 'loss_prevention')
* - skillIds: string[] (e.g. ['skill_sla_guardian', 'skill_fleet_balancer'])
* - title: string (Custom banner title)
* - maxItems: number (Default 4)
* - hideWhenClean: boolean (If true, renders nothing when all clear)
*/
export default function AgentOperationsBanner({
category,
skillIds,
title = 'Early warnings',
maxItems = 4,
hideWhenClean = false,
className = ''
}) {
const navigate = useNavigate();
// Which settings the rules are running on — the registry's, or the code
// defaults while it is unreachable. Said on screen, so a tuned threshold is
// never assumed when it is not in force.
const [settingsSource, setSettingsSource] = useState(() => SkillRegistry.getSource());
const {
data: allFindings = [],
isLoading,
refetch,
isFetching
} = useQuery({
queryKey: ['doormile', 'agent-operations-briefing'],
queryFn: async () => {
const scan = await scanBookings();
const agentRows = toAgentRows(scan?.rows);
const agent = AgentFactory.synthesizeDefaultAgent();
return agent.evaluateTelemetry(agentRows, wallClockNow());
},
staleTime: 30_000,
refetchInterval: 60_000,
});
// Carry out a proposal, then re-evaluate.
//
// The refetch is the point, not politeness: an agent that fires an action and
// never looks again is a button. Re-running the scan is what turns this into
// a loop — the finding either disappears, proving the action landed, or it
// survives and stays on the board.
//
// Errors are re-thrown so SlaRemediationCard shows its failure state; a
// partial success (six riders reached out of eight) resolves ok and the
// message names the ones that were missed.
const handleRemediate = useCallback(
async (finding) => {
const result = await executeProposal(finding);
if (!result?.ok) {
throw new Error(result?.message || 'The action did not complete.');
}
await refetch();
return result;
},
[refetch]
);
// Re-evaluate when the registry's settings arrive or change (a save in
// Settings → Skills & Tools refetches the registry, which lands here).
useEffect(() => {
const unsubscribe = SkillRegistry.subscribe(() => {
setSettingsSource(SkillRegistry.getSource());
refetch();
});
return unsubscribe;
}, [refetch]);
// Contextual page-level filtering
const findings = useMemo(() => {
if (!category && !skillIds) {
return allFindings;
}
const targetCategories = category
? Array.isArray(category)
? category
: [category]
: null;
const targetSkills = skillIds ? new Set(skillIds) : null;
return allFindings.filter((finding) => {
const skill = SkillRegistry.getSkill(finding.skillId);
if (targetSkills && finding.skillId && !targetSkills.has(finding.skillId)) {
return false;
}
if (targetCategories && skill && !targetCategories.includes(skill.category)) {
return false;
}
return true;
});
}, [allFindings, category, skillIds]);
if (isLoading) {
return null;
}
const hasFindings = findings.length > 0;
if (hideWhenClean && !hasFindings) {
return null;
}
return (
<>
<div className={`space-y-3 p-4 rounded-2xl bg-surface-2 border border-line ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-mono font-bold uppercase tracking-wider text-accent-ink">
<Sparkles className="h-4 w-4" />
<span>{title}</span>
<span className="text-ink-4">({findings.length} detected)</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate('/doormile/settings?tab=skills')}
className="h-7 px-2.5 text-xs text-ink-2 hover:text-ink flex items-center gap-1.5 border-line shadow-none"
title="Settings → Skills & Tools: enable skills and tune thresholds"
>
<SlidersHorizontal className="h-3.5 w-3.5 text-accent-ink" />
<span>Configure skills</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
className="h-7 px-2 text-xs text-ink-3 hover:text-ink flex items-center gap-1.5"
title="Re-run telemetry diagnosis"
>
<RefreshCw className={`h-3.5 w-3.5 ${isFetching ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</Button>
</div>
</div>
{settingsSource !== 'registry' && (
<p role="note" className="text-[11px] text-ink-3">
Running on default thresholds — the skill settings from Settings → Skills &amp; Tools could not be loaded.
</p>
)}
{hasFindings ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{findings.slice(0, maxItems).map((finding, idx) => (
<SlaRemediationCard
key={finding.id || idx}
finding={finding}
// Only proposals with a real executor become clickable. The rest
// render disabled as "Review only" rather than implying an action.
onRemediate={canExecuteProposal(finding) ? handleRemediate : undefined}
/>
))}
</div>
) : (
<div className="text-xs text-ink-3 py-1 flex items-center gap-1.5 font-mono">
<span className="h-2 w-2 rounded-full bg-emerald-500" />
Nothing flagged by the {SkillRegistry.getActiveSkills().length} enabled skills.
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,137 @@
import React, { useState } from 'react';
import {
Sparkles,
ShieldAlert,
CheckCircle2,
RefreshCw,
Zap
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { OpenToast } from '@/api/doormile/notify';
/**
* Proactive SLA Risk & Self-Healing Remediation Card.
* Detects aging, stalled, or at-risk parcels and presents a 1-click mitigation plan.
*/
export default function SlaRemediationCard({ finding, onRemediate }) {
const [remediated, setRemediated] = useState(false);
const [loading, setLoading] = useState(false);
if (!finding) return null;
const { title, why, severity = 'warning', count = 1, proposal } = finding;
// A proposal is only actionable when a caller supplies an executor. Without
// one there is no write path — the agent's mutating tools deliberately return
// proposals rather than performing the write themselves.
const canExecute = typeof onRemediate === 'function';
const handleApply = async () => {
// Guarded rather than merely disabled: a click that cannot act must never
// reach the success path.
//
// This previously fell through to `setTimeout(600)` and then reported
// "Remediation executed" with a green card — and no caller has ever passed
// onRemediate, so EVERY click took that branch. An operator was told a
// stalled rider had been messaged when nothing had been sent. A button that
// lies about acting is worse than no button, exactly as a wrong number is
// worse than no answer (assistant/CLAUDE.md §3).
if (!canExecute) return;
setLoading(true);
try {
const result = await onRemediate(finding);
setRemediated(true);
// OpenToast is (message, type). The branch called it ('success', msg), so
// every toast would have read just "success". The executor's own message
// is shown because it counts partial success ("Notified 6 … could not
// reach 2: …"), which a fixed string would hide.
OpenToast(result?.message || `Done: ${proposal.label}`, 'success');
} catch (err) {
OpenToast(err?.message || 'The action did not complete', 'error');
} finally {
setLoading(false);
}
};
const isCritical = severity === 'critical';
return (
<div
className={`rounded-xl border p-4 transition-all duration-200 ${
remediated
? 'bg-emerald-50/50 border-emerald-200'
: isCritical
? 'bg-rose-50/40 border-rose-200 hover:border-rose-300'
: 'bg-amber-50/40 border-amber-200 hover:border-amber-300'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<div
className={`p-2 rounded-lg ${
remediated
? 'bg-emerald-100 text-emerald-700'
: isCritical
? 'bg-rose-100 text-rose-700'
: 'bg-amber-100 text-amber-700'
}`}
>
{remediated ? <CheckCircle2 className="h-4 w-4" /> : <ShieldAlert className="h-4 w-4" />}
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider font-mono text-ink">
{/* Not "Critical SLA Risk" / "Autonomous Finding": findings come
from cash and fleet rules too, and nothing here acts on its own. */}
{remediated ? 'Action sent' : isCritical ? 'Needs attention now' : 'Worth a look'}
</span>
<Badge variant="outline" className="text-[10px] h-4 py-0 font-mono">
{count} {count === 1 ? 'Order' : 'Orders'}
</Badge>
</div>
<h4 className="text-sm font-semibold text-ink mt-0.5">{title}</h4>
</div>
</div>
{!remediated && proposal && (
<Button
size="sm"
className={`text-xs gap-1.5 font-medium shadow-sm ${
isCritical
? 'bg-rose-600 hover:bg-rose-700 text-white'
: 'bg-amber-600 hover:bg-amber-700 text-white'
}`}
onClick={handleApply}
disabled={loading || !canExecute}
title={
canExecute
? proposal.blastRadius
: 'Review only — no execution path is wired for this action yet'
}
>
{loading ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
) : (
<Zap className="h-3.5 w-3.5 fill-current" />
)}
{canExecute ? proposal.label : 'Review only'}
</Button>
)}
</div>
<p className="text-xs text-ink-2 mt-2 leading-relaxed">{why}</p>
{proposal && !remediated && (
<div className="mt-3 pt-2.5 border-t border-line/60 flex items-center justify-between text-xs text-ink-3">
<span className="flex items-center gap-1">
<Sparkles className="h-3.5 w-3.5 text-accent-ink" />
Proposed Action: <strong className="text-ink">{proposal.label}</strong>
</span>
<span className="text-[11px] text-ink-4">{proposal.blastRadius}</span>
</div>
)}
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { motion } from 'framer-motion';
import {
Activity, Bell, Bike, Bot, Car, ChevronDown, Coins, FileSpreadsheet,
FileText, ListTodo, LogOut, Menu, Search, Settings, Shield,
ShieldAlert, User, UserCheck, Warehouse,
ShieldAlert, User, UserCheck, UserPlus, Warehouse,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { DOORMILE_MARK_URL } from '@/assets/brand';
@@ -16,9 +16,11 @@ import {
} from '@/components/ui/dropdown-menu';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useAuth } from '@/lib/AuthContext';
import { canOnboardClients } from '@/lib/clientOnboarding';
import { useExceptions } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import AIPanel from '@/components/assistant/DoormileAI/AIPanel';
import { useSkillRegistrySync } from '@/lib/assistant/skills/useSkillRegistrySync';
import AILauncher from '@/components/assistant/DoormileAI/AILauncher';
/**
@@ -221,6 +223,10 @@ export default function AdminLayout() {
const location = useLocation();
const navigate = useNavigate();
const { user, isClient, logout } = useAuth();
// One sync for the whole console, so the Exceptions banner and the chat's
// "what needs attention" briefing run on the same registry settings. Partner
// logins cannot read the registry (403) and run on code defaults.
useSkillRegistrySync(!isClient);
const [menuOpen, setMenuOpen] = useState(false);
const [assistantOpen, setAssistantOpen] = useState(() => {
try {
@@ -251,12 +257,21 @@ export default function AdminLayout() {
return NAV;
}, [isClient]);
const canOnboard = canOnboardClients(user, isClient);
const navGroups = useMemo(() => {
if (isClient) {
return NAV_GROUPS.filter((group) => group.label !== 'Fleet Ops');
}
return NAV_GROUPS;
}, [isClient]);
if (!canOnboard) return NAV_GROUPS;
// Only the onboarding owner login sees this entry; the server refuses
// everyone else regardless.
return NAV_GROUPS.map((group) =>
group.label === 'Fleet Ops'
? { ...group, items: [...group.items, { label: 'Client Onboarding', path: '/doormile/clients/onboard', icon: UserPlus }] }
: group
);
}, [isClient, canOnboard]);
const allDestinations = useMemo(() => {
return [...navItems, ...navGroups.flatMap((group) => group.items)];

View File

@@ -0,0 +1,92 @@
// ==============================|| Doormile AI — Autonomous Agent Factory ||============================== //
import { SkillRegistry } from '../skills/SkillRegistry';
import { wallClockNow } from './signals';
const SEVERITY_RANK = { critical: 0, warning: 1, watch: 2 };
export class AgentFactory {
/**
* Compiles an Agent configuration from a collection of skills.
*/
static createAgent(skills = SkillRegistry.getActiveSkills()) {
const activeSkills = Array.isArray(skills) ? skills : [];
// 1. Synthesize System Prompt
const instructions = activeSkills
.map((s) => `### ${s.name} (${s.category})\n${(s.instructions || '').trim()}`)
.join('\n\n');
const systemPrompt = `
You are the Doormile Autonomous Logistics Fleet Orchestrator.
You operate with the following active operational skill modules:
${instructions || 'Standard logistics routing and telemetry diagnostics.'}
Guiding Invariant:
Read-only queries execute instantly. Mutating actions (reassignments, pings, OTP enforcement) MUST return human-in-the-loop proposals with clear blast radius.
`.trim();
// 2. Aggregate Tools from active skills
const tools = activeSkills.flatMap((s) => s.tools || []);
const toolMap = new Map();
tools.forEach((t) => toolMap.set(t.name, t));
// 3. Telemetry Evaluation Engine
const evaluateTelemetry = (rows = [], now = wallClockNow()) => {
const allFindings = [];
for (const skill of activeSkills) {
if (typeof skill.evaluate === 'function') {
// Extract current threshold values
const rawThresholds = {};
if (skill.effectiveThresholds) {
Object.entries(skill.effectiveThresholds).forEach(([k, v]) => {
rawThresholds[k] = v.currentValue;
});
} else if (skill.thresholds) {
Object.entries(skill.thresholds).forEach(([k, v]) => {
rawThresholds[k] = v.value;
});
}
const findings = skill.evaluate(rows, now, rawThresholds);
if (Array.isArray(findings)) {
allFindings.push(...findings);
}
}
}
// Sort by severity (critical > warning > watch), then by affected row count descending
return allFindings.sort(
(a, b) =>
(SEVERITY_RANK[a.severity] ?? 99) - (SEVERITY_RANK[b.severity] ?? 99) ||
(b.count || 0) - (a.count || 0)
);
};
return {
name: 'Doormile Fleet Orchestrator',
version: '2.0.0',
activeSkillCount: activeSkills.length,
skills: activeSkills,
tools,
toolMap,
systemPrompt,
evaluateTelemetry,
executeSkillTool: async (toolName, args) => {
const tool = toolMap.get(toolName);
if (!tool || typeof tool.handler !== 'function') {
return { success: false, error: `Skill tool "${toolName}" not found or has no handler.` };
}
return tool.handler(args);
}
};
}
/**
* Helper to build the default active agent runtime based on current SkillRegistry state.
*/
static synthesizeDefaultAgent() {
return AgentFactory.createAgent(SkillRegistry.getActiveSkills());
}
}

View File

@@ -0,0 +1,153 @@
// ==============================|| Doormile AI — proposal executors ||============================== //
//
// The layer that turns a finding's proposal into a real API call.
//
// It exists as its own module because the skills deliberately have no API
// access at all — not one of them imports an endpoint, and their tool handlers
// only build proposal objects. That separation is worth keeping: a skill stays
// a pure function of rows and thresholds, which is why all eight are testable
// without a network. Execution is the part with consequences, so it lives in
// one place where the failure modes can be handled once.
//
// Two rules, both learned the hard way in assignActions.js:
//
// • Never report an action that did not happen. A card that says "rider
// notified" when the push returned 400 is worse than one that says nothing:
// a dispatcher who believes a rider was pinged does not follow up.
// • A partial success is a partial success. Notifying six riders out of eight
// is reported as six out of eight, with the two failures named.
import { getMilers, buildMilerLookup, notifyRider } from '@/api/doormile';
/** A stable, human-facing reference for a row. */
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
/**
* Rider pushes key off milerprofileid; findings carry mileruserid.
*
* These are two different small integers on the same record, and using the
* wrong one fails silently in both directions — the notify 404s, or a different
* rider is paged. buildMilerLookup is the same bridge the Orders page uses;
* this must never grow a second one.
*/
const profileIdFor = (row, lookup) => lookup.byUserId.get(String(row?.userid))?.milerprofileid;
// ---- notifyRider ------------------------------------------------------------
const executeNotifyRider = async (scope, message) => {
const rows = (scope || []).filter((r) => r?.userid);
if (!rows.length) {
return { ok: false, message: 'None of these parcels has an assigned rider to notify.', sourceCalls: [] };
}
// One fetch for the whole batch. Per row would be an identical request each
// time, and the lookup is the same for all of them.
const milers = await getMilers();
const lookup = buildMilerLookup(milers);
const sourceCalls = [];
const notified = [];
const unreachable = [];
for (const row of rows) {
const profileId = profileIdFor(row, lookup);
// No profile id means no push is possible. Recorded rather than skipped,
// so the operator is not left assuming a phone buzzed.
if (!profileId) {
unreachable.push(`${ref(row)} (${row.ridername || 'rider'}: no device registered)`);
continue;
}
try {
// eslint-disable-next-line no-await-in-loop
await notifyRider(profileId, 'Doormile Ops', message);
notified.push(ref(row));
sourceCalls.push({
name: 'notifyRider',
target: `POST /admin/milers/${profileId}/notify`,
status: 'complete',
stats: `${ref(row)} → ${row.ridername || 'rider'}`
});
} catch (err) {
unreachable.push(`${ref(row)} (${err?.message || 'push failed'})`);
sourceCalls.push({
name: 'notifyRider',
target: `POST /admin/milers/${profileId}/notify`,
status: 'error',
errorMessage: err?.message || 'notification failed'
});
}
}
const parts = [];
if (notified.length) parts.push(`Notified ${notified.length} rider${notified.length === 1 ? '' : 's'}.`);
if (unreachable.length) parts.push(`Could not reach ${unreachable.length}: ${unreachable.join('; ')}.`);
return {
ok: notified.length > 0,
message: parts.join(' ') || 'Nothing was sent.',
notified,
unreachable,
sourceCalls
};
};
// ---- assignMiler: deliberately NOT an executor --------------------------------
//
// On the branch this called batchAssignBookings → POST /hub/bookings/batch-assign.
// That route sits behind middlewares.HubStaffAuth, which refuses every token
// whose role is not 6 (hub staff) — so from this console, where every login is
// admin/manager/executive, it would 403 on every click. Shipping it would have
// put a button on the Exceptions banner that could never succeed.
//
// Findings that propose `assignMiler` therefore render "Review only". The
// admin route that could back it, POST /admin/bookings/:id/assign-miler, needs
// a chosen rider per booking, which a finding does not pick. Wiring this is a
// backend decision (an admin batch-assign route), not a console one.
// ---- registry ---------------------------------------------------------------
/**
* Keyed on the `tool` a finding's proposal actually carries.
*
* Deliberately NOT the skills' declared tool names (ping_stalled_rider,
* reassign_sla_critical_order, …). Those belong to a separate, currently unused
* layer whose handlers only build proposals; the UI renders `proposal.tool`,
* and that is what an operator's click has to resolve.
*
* Verbs with no entry here have no endpoint behind them yet. That is not an
* error — the card renders them disabled and says "Review only" rather than
* pretending.
*/
const EXECUTORS = {
notifyRider: (finding) =>
executeNotifyRider(
finding?.proposal?.scope,
finding?.severity === 'critical'
? 'Urgent: this delivery needs attention now. Please check the app.'
: 'Please check this delivery in the app when you can.'
)
// assignMiler: none — see the note above. Its findings render "Review only".
};
/** Whether a finding's proposal can actually be carried out. */
export const canExecuteProposal = (finding) => Boolean(EXECUTORS[finding?.proposal?.tool]);
/** Every verb that currently has a real executor behind it. */
export const executableTools = () => Object.keys(EXECUTORS);
/**
* Runs a finding's proposal.
*
* Throws when there is no executor rather than resolving quietly, so a caller
* cannot mistake "nothing happened" for success. The UI is expected to have
* checked canExecuteProposal first and disabled the control.
*/
export const executeProposal = async (finding) => {
const run = EXECUTORS[finding?.proposal?.tool];
if (!run) {
throw new Error(`No executor for "${finding?.proposal?.tool}" — this proposal is review-only.`);
}
return run(finding);
};

View File

@@ -0,0 +1,112 @@
// ==============================|| Doormile AI — the ops briefing ||============================== //
//
// Turns the findings from signals.js into the shape the assistant panel already
// renders: a headline, a detail body, and a metric. Pure — it takes rows and an
// instant and returns an object, so the whole briefing can be tested without a
// network, a clock, or React.
//
// This is the agent's "explain" step. Every line it produces is traceable to a
// rule in signals.js and to the rows that rule matched, because an operator who
// cannot see why the agent believes something will not act on it — and an
// unexplained instruction from a bot is exactly the thing CLAUDE.md §3 refuses
// to ship.
import { AgentFactory } from './AgentFactory';
import { ALL_CLEAR, wallClockNow } from './signals';
import { toAgentRows } from './normalise';
const SEVERITY_LABEL = {
critical: 'Needs attention now',
warning: 'Worth looking at',
watch: 'Keep an eye on'
};
/**
* A truncated scan is a floor, not a total.
*
* `/admin/bookings` caps pagesize, so a scan drains pages up to a budget and
* reports whether it ran out. Every count built on a truncated scan says "at
* least", because the alternative — stating a total that is quietly a subset —
* is the exact failure the assistant's verifiability contract exists to
* prevent. A missed breach is worse than an approximate one, so the briefing
* still runs; it just stops claiming completeness.
*/
export const countPrefix = (truncated) => (truncated ? 'at least ' : '');
/** One finding rendered as a block of lines. */
const renderFinding = (finding, truncated) => {
const lines = [];
lines.push(`${SEVERITY_LABEL[finding.severity]} — ${finding.title}`);
lines.push(finding.why);
if (finding.detail?.length) {
lines.push(...finding.detail.map((d) => ` • ${d}`));
const shown = finding.detail.length;
if (finding.count > shown) {
lines.push(` • …and ${countPrefix(truncated)}${finding.count - shown} more`);
}
}
if (finding.proposal) {
lines.push(` → ${finding.proposal.label} (${finding.proposal.blastRadius})`);
}
return lines.join('\n');
};
/**
* Builds the full briefing.
*
* `scan` is the result of the assistant's own booking scan — `{ rows,
* truncated }` — so the briefing inherits the same data the rest of the
* catalog answers from and cannot disagree with it.
*/
export const buildBriefing = (scan, now = wallClockNow()) => {
// Raw `/admin/bookings` rows in, agent rows out. Without this the rules read
// undefined on every field and the briefing reports an all-clear board.
const rows = toAgentRows(scan?.rows);
const truncated = Boolean(scan?.truncated);
// The SAME engine the Exceptions banner runs, deliberately.
//
// This used to call a private set of rules in signals.js that duplicated five
// of the skills and hardcoded their thresholds. The two drifted immediately —
// signals.js flagged a stalled rider at 25 minutes while DoorstepStallSkill
// used 20 — so the chat panel and the banner disagreed about the same parcel,
// and a threshold moved in Agent Studio changed one surface but not the other.
//
// Going through the registry means there is one definition of every rule, one
// place thresholds live, and the chat panel sees every enabled skill rather
// than the five somebody happened to reimplement here.
const findings = AgentFactory.synthesizeDefaultAgent().evaluateTelemetry(rows, now);
if (!findings.length) {
return {
headline: ALL_CLEAR,
detail: truncated
? 'Note: the booking scan hit its page budget, so this covers the most recent orders rather than every one.'
: `Checked ${rows.length} open and recent bookings.`,
findings,
metric: { value: 0, label: 'issues found' }
};
}
const critical = findings.filter((f) => f.severity === 'critical');
const affected = new Set(findings.flatMap((f) => f.rows.map((r) => r.bookingid))).size;
const headline = critical.length
? `${critical.length} thing${critical.length === 1 ? '' : 's'} need${critical.length === 1 ? 's' : ''} attention now, across ${countPrefix(truncated)}${affected} order${affected === 1 ? '' : 's'}.`
: `Nothing critical, but ${findings.length} thing${findings.length === 1 ? '' : 's'} worth looking at across ${countPrefix(truncated)}${affected} order${affected === 1 ? '' : 's'}.`;
const body = findings.map((f) => renderFinding(f, truncated)).join('\n\n');
const footer = truncated
? '\n\nThe booking scan hit its page budget, so these counts are a floor — there may be more.'
: '';
return {
headline,
detail: `${body}${footer}`,
findings,
metric: { value: findings.length, label: findings.length === 1 ? 'issue found' : 'issues found' }
};
};

View File

@@ -0,0 +1,53 @@
// ==============================|| Doormile AI — scan row adapter ||============================== //
//
// The assistant's booking scan returns RAW rows straight off `/admin/bookings`
// — `status`, `createdat`, `assignedmileruserid`, `serviceoptions[0]` — not the
// normalised delivery rows the Deliveries page renders. The two shapes share
// almost no field names, so the agent's rules cannot read a scan row directly.
//
// This adapter is the seam. It exists as its own file because getting it wrong
// fails silently in the worst possible way: every rule reads `undefined`, every
// rule matches nothing, and the agent cheerfully reports "nothing needs
// attention" on a board that is on fire. That is precisely the failure mode
// CLAUDE.md §3 exists to prevent, and it is invisible without a test.
//
// The status derivation deliberately goes through the console's own
// `mapBookingStatusToDeliveryStatus` rather than a local copy — the same
// function the Deliveries page's table calls. That is the house rule (never
// hand-roll a second data path) and it is also what guarantees the agent and
// the screen can never disagree about what state a parcel is in.
import { mapBookingStatusToDeliveryStatus } from '@/api/doormile/queries';
/** The rider's arrival stamp, which the API has spelled several ways. */
const reachedAtOf = (b) => b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time;
/** Rider display name, from whichever join carried it. */
const riderNameOf = (b) =>
b.milername || b.ridername || b.assignedmilername || b.miler?.name || undefined;
/**
* One raw booking row → the shape signals.js reads.
*
* Every field is derived, never invented: a raw row that carries no service
* option yields `expecteddeliverytime: undefined`, and the SLA rules skip it
* rather than treating a missing promise as a kept or broken one.
*/
export const toAgentRow = (b) => ({
bookingid: b.bookingid ?? b.id,
orderid: b.bookingno || (b.bookingid ? `#${b.bookingid}` : '—'),
// Booking status, consignment status and reachedat resolved by the console's
// own mapper — including the Converted_To_Consignment handoff, where the
// lifecycle moves onto the consignment and the booking's status freezes.
orderstatus: mapBookingStatusToDeliveryStatus(b),
orderdate: b.createdat || b.orderdate || b.updatedat,
expecteddeliverytime: b.serviceoptions?.[0]?.estimateddeliveryat,
reachedat: reachedAtOf(b),
userid: b.assignedmileruserid ?? b.mileruserid,
ridername: riderNameOf(b)
});
/** A whole scan's rows, adapted. Non-array input yields an empty list. */
export const toAgentRows = (rows) => (Array.isArray(rows) ? rows.map(toAgentRow) : []);

View File

@@ -0,0 +1,44 @@
// ==============================|| Doormile AI — shared agent primitives ||============================== //
//
// What is left after the rules moved out.
//
// This file used to hold its own copy of five detection rules — breached SLA,
// at-risk SLA, aging unassigned work, doorstep stalls, rider saturation — each
// with a hardcoded threshold in a local THRESHOLDS object. Every one of those
// rules already existed as a skill in skills/definitions/, with the same
// finding id and a threshold an operator can tune in Agent Studio.
//
// Two implementations of one rule is a bug with a delay fuse, and this one had
// already fired: signals.js flagged a doorstep stall at 25 minutes while
// DoorstepStallSkill used 20, so the chat panel and the Exceptions banner
// disagreed about the same rider, out of the box, before anyone touched a
// slider. Worse, the hardcoded copy could not see the registry at all — tuning
// a threshold moved the banner and left the chat panel where it was.
//
// The rules now live in exactly one place: skills/definitions/. Everything that
// needs findings goes through AgentFactory, which reads the registry, so there
// is one definition per rule and one place a threshold can be changed.
//
// What remains here is the small shared vocabulary that is genuinely common to
// every consumer and belongs to none of them.
import dayjs from 'dayjs';
/**
* "Now", in the same form every Doormile timestamp is stored in.
*
* NOT `new Date().toISOString()`. That returns UTC, and parseDoormileTimestamp
* deliberately strips any zone marker and reads the remaining digits as IST
* wall-clock — so an ISO string would hand the rules a clock running 5h30m
* slow, and every parcel less than five and a half hours overdue would look
* like it was still in the future. The agent would report an all-clear board
* through most of a working day.
*
* This was a live bug. It passed every unit test, because the tests all passed
* `now` explicitly and only the default was wrong.
*/
export const wallClockNow = () => dayjs().format('YYYY-MM-DD HH:mm:ss');
/** Shown when every enabled skill returns no findings. */
export const ALL_CLEAR =
'Nothing needs attention right now — no breached promises, no stalled riders, and no aging unassigned work.';

View File

@@ -22,6 +22,7 @@ import {
getTenantLocations
} from 'pages/api/doormileApi';
import { getalltenants, getallridersummary } from 'pages/api/api';
import { buildBriefing } from '@/lib/assistant/agent/briefing';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { getRowBatchId, getBatchLabel, BATCHES } from 'utils/batchBucket';
import { STATUS } from 'themes/dt/tokens';
@@ -745,6 +746,48 @@ const COMPARE_TRIGGER = /\bvs\b|\bversus\b|\bcompared?\s*to\b|\bcompare\b/i;
const MULTI_SPLIT = /\band\b|,|\+|&/i;
const INTENTS = [
{
// The ops briefing: every monitoring skill enabled in the agent registry,
// run over the same booking scan the rest of the catalog answers from —
// the SAME engine as the Exceptions banner (AgentFactory), so the chat and
// the banner can never disagree about a parcel. Ported from
// feat/agentic-ops-layer (docs/agent-platform-plan.md, Phase 3).
//
// FIRST on purpose: later intents match bare "orders"/"riders", which
// appear in most phrasings of a sweep.
//
// Narrower than the branch on purpose. The branch also claimed
// "late/overdue/delayed orders", which would have stolen the existing
// delayed-orders answer (a count on the Orders taxonomy) and replaced it
// with a briefing. Only an explicit request for a sweep lands here.
id: 'opsBriefing',
label: 'What needs attention right now — e.g. "what needs attention", "anything going wrong", "ops check"',
match: (text) => {
const t = String(text).toLowerCase();
const asksForSweep =
/\b(what|anything|any)\b.{0,24}\b(needs?\s+(my\s+)?attention|going\s+wrong|at\s+risk|urgent|on\s+fire)\b/.test(t) ||
/\b(ops|operations|status|health|daily)\s+(check|sweep|briefing|brief)\b/.test(t) ||
/\bwhat\s+should\s+i\s+(do|look\s+at)\b/.test(t);
return asksForSweep ? {} : null;
},
run: async () => {
const scan = await scanBookings();
const briefing = buildBriefing(scan);
return {
headline: briefing.headline,
detail: briefing.detail,
metric: briefing.metric,
sourceCalls: [
scanCall(
scan,
briefing.findings.length
? `${briefing.findings.length} finding${briefing.findings.length === 1 ? '' : 's'} over ${scan.rows.length} rows`
: `no issues over ${scan.rows.length} rows`
)
]
};
}
},
{
// Ordered ahead of BOTH create triggers. "repeat yesterday's orders"
// contains "orders", so createBulkOrders and createOrder would otherwise

View File

@@ -0,0 +1,123 @@
// ==============================|| Doormile AI — Skill Registry ||============================== //
//
// The console's eight monitoring skills: their RULES live in code
// (./definitions), their SETTINGS — enabled, thresholds — live in the backend
// agent registry (/admin/ai/skills, edited in Settings → Skills & Tools). This
// class joins the two.
//
// On the feat/agentic-ops-layer branch these settings were kept in the
// browser's localStorage, so a threshold tuned on one machine changed nothing
// on any other, and two operators could look at the same board through
// different rules. Ported onto main in Phase 3 of docs/agent-platform-plan.md,
// the settings come from the registry instead, and nothing is stored locally.
//
// Until the registry answers (first load, a network error, a partner login
// that is not allowed to read it) every skill runs on its code defaults, and
// `getSource()` says so, so the banner can tell the operator which settings it
// is running on rather than implying they are the tuned ones.
import { SlaGuardianSkill } from './definitions/SlaGuardianSkill';
import { DoorstepStallSkill } from './definitions/DoorstepStallSkill';
import { FleetBalancerSkill } from './definitions/FleetBalancerSkill';
import { HighValueCodSkill } from './definitions/HighValueCodSkill';
import { RiderBatterySafetySkill } from './definitions/RiderBatterySafetySkill';
import { HubCongestionSkill } from './definitions/HubCongestionSkill';
import { LateDispatchSkill } from './definitions/LateDispatchSkill';
import { CashExposureSkill } from './definitions/CashExposureSkill';
export const DEFAULT_SKILLS = [
SlaGuardianSkill,
DoorstepStallSkill,
FleetBalancerSkill,
HighValueCodSkill,
RiderBatterySafetySkill,
HubCongestionSkill,
LateDispatchSkill,
CashExposureSkill
];
/** A stored value outside the definition's range falls back to its default. */
const inRange = (config, v) =>
typeof v === 'number' && Number.isFinite(v) && v >= config.min && v <= config.max;
class SkillRegistryClass {
constructor() {
this.skills = new Map();
this.listeners = new Set();
this.config = {}; // { [skillId]: { enabled: boolean, thresholds: { [key]: number } } }
this.source = 'defaults';
DEFAULT_SKILLS.forEach((skill) => this.skills.set(skill.id, skill));
}
/**
* Adopt the registry's settings. `rows` is the /admin/ai/skills response;
* rows for skills this console does not implement are ignored.
*/
applyRegistryConfig(rows) {
const next = {};
(Array.isArray(rows) ? rows : []).forEach((row) => {
if (!row || !this.skills.has(row.skillid)) return;
next[row.skillid] = {
enabled: Boolean(row.enabled),
thresholds: row.thresholds && typeof row.thresholds === 'object' ? row.thresholds : {}
};
});
this.config = next;
this.source = 'registry';
this.notify();
}
/** Forget the registry's settings and run on code defaults. */
clearRegistryConfig() {
if (this.source === 'defaults' && !Object.keys(this.config).length) return;
this.config = {};
this.source = 'defaults';
this.notify();
}
/** 'registry' when running on the tuned settings, 'defaults' otherwise. */
getSource() {
return this.source;
}
getAllSkills() {
return Array.from(this.skills.values()).map((skill) => {
const cfg = this.config[skill.id];
const enabled = cfg ? cfg.enabled : (skill.defaultEnabled ?? true);
const effectiveThresholds = {};
Object.entries(skill.thresholds || {}).forEach(([key, config]) => {
const stored = cfg?.thresholds?.[key];
effectiveThresholds[key] = { ...config, currentValue: inRange(config, stored) ? stored : config.value };
});
return { ...skill, enabled, effectiveThresholds };
});
}
getActiveSkills() {
return this.getAllSkills().filter((s) => s.enabled);
}
getSkill(skillId) {
return this.getAllSkills().find((s) => s.id === skillId);
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
notify() {
const all = this.getAllSkills();
this.listeners.forEach((fn) => {
try {
fn(all);
} catch {
// One listener failing must not stop the others hearing the change.
}
});
}
}
export const SkillRegistry = new SkillRegistryClass();

View File

@@ -0,0 +1,161 @@
// ==============================|| Skill: Cash Exposure Agent ||============================== //
//
// Monitors the total live COD (Cash on Delivery) cash a single rider is carrying
// across ALL their active orders. When total exposure exceeds the safe limit,
// proposes a mandatory cash handoff stop at the nearest hub.
//
// This is distinct from HighValueCodSkill, which monitors per-order value.
// This skill monitors per-rider TOTAL cash accumulation across their full run.
const ACTIVE_COD_STATUSES = new Set(['accepted', 'picked', 'active', 'arrived']);
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const CashExposureSkill = {
id: 'skill_cash_exposure',
name: 'Cash Exposure Agent',
description: 'Monitors total live COD cash per rider across all active orders. Flags when a rider carries unsafe cash levels and proposes hub handoff.',
category: 'loss_prevention',
icon: 'ShieldCheck',
// OFF by default (2026-09-29): the rows this rule reads carry no COD amount per order: /admin/bookings does not preload bookingpayments.
// Enabled, it would read undefined on every row and report an all-clear board —
// the silent failure normalise.js warns about. Turn it on only once the data exists.
dataGap: 'no COD amount per order: /admin/bookings does not preload bookingpayments',
defaultEnabled: false,
thresholds: {
maxCashPerRider: {
label: 'Max Safe Cash Per Rider',
description: 'Maximum total COD cash (₹) a single rider should carry simultaneously across all active orders.',
value: 10000,
min: 2000,
max: 50000,
step: 1000,
unit: '₹'
},
warningCashPercent: {
label: 'Warning Threshold (%)',
description: 'Percentage of max cash limit at which to issue a warning (before it becomes critical).',
value: 75,
min: 50,
max: 95,
step: 5,
unit: '%'
}
},
instructions: `
- A rider carrying excessive COD cash is a major financial risk if the phone dies, the rider is delayed, or there is a dispute.
- Flag riders above the cash threshold for a mandatory hub cash handoff before continuing deliveries.
- Critical: stop new COD orders being assigned to flagged riders until handoff is confirmed.
`,
tools: [
{
name: 'enforce_cash_handoff',
description: 'Proposes a mandatory cash handoff stop at the nearest hub for an overexposed rider.',
parameters: {
type: 'object',
properties: {
milerId: { type: 'number', description: 'Rider user ID carrying excessive cash' },
totalCash: { type: 'number', description: 'Total COD cash the rider currently holds (₹)' },
hubId: { type: 'string', description: 'Nearest hub for handoff' }
},
required: ['milerId', 'totalCash']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'cash_handoff',
milerId: args.milerId,
totalCash: args.totalCash,
hubId: args.hubId || 'nearest',
blastRadius: `Reroutes rider to nearest hub for cash handoff of ₹${args.totalCash?.toLocaleString('en-IN')}. Pauses new COD order assignments to this rider until confirmed.`
}
})
}
],
evaluate: (rows = [], _now, currentThresholds = {}) => {
const maxCash = currentThresholds.maxCashPerRider ?? 10000;
const warningPct = (currentThresholds.warningCashPercent ?? 75) / 100;
const warningLimit = maxCash * warningPct;
const findings = [];
// Group active COD orders by rider
const byRider = {};
rows
.filter((r) =>
ACTIVE_COD_STATUSES.has(String(r?.orderstatus || '').toLowerCase()) &&
r?.paymenttype &&
String(r.paymenttype).toLowerCase().includes('cod') &&
r?.userid
)
.forEach((r) => {
const uid = r.userid;
if (!byRider[uid]) {
byRider[uid] = { riderId: uid, riderName: r.ridername || r.miler_name || `Rider ${uid}`, orders: [] };
}
byRider[uid].orders.push(r);
});
const criticalRiders = [];
const warningRiders = [];
Object.values(byRider).forEach((riderData) => {
const total = riderData.orders.reduce((sum, r) => {
const amt = parseFloat(r.cod_amount || r.orderamount || r.totalamount || 0);
return sum + (isNaN(amt) ? 0 : amt);
}, 0);
riderData.totalCash = total;
if (total >= maxCash) {
criticalRiders.push(riderData);
} else if (total >= warningLimit) {
warningRiders.push(riderData);
}
});
criticalRiders.sort((a, b) => b.totalCash - a.totalCash);
warningRiders.sort((a, b) => b.totalCash - a.totalCash);
if (criticalRiders.length) {
findings.push({
id: 'cash_exposure_critical',
skillId: 'skill_cash_exposure',
severity: 'critical',
title: `${criticalRiders.length} rider${criticalRiders.length === 1 ? '' : 's'} carrying ≥₹${maxCash.toLocaleString('en-IN')} in COD cash`,
why: `Total COD cash per rider exceeds the safe limit of ₹${maxCash.toLocaleString('en-IN')}. Immediate hub handoff required.`,
count: criticalRiders.length,
rows: criticalRiders.flatMap((rd) => rd.orders),
detail: criticalRiders.slice(0, 8).map(
(rd) => `${rd.riderName} — ₹${rd.totalCash.toLocaleString('en-IN')} across ${rd.orders.length} active COD order${rd.orders.length === 1 ? '' : 's'}`
),
proposal: {
label: 'Enforce cash handoff at hub',
tool: 'enforce_cash_handoff',
scope: criticalRiders.flatMap((rd) => rd.orders),
blastRadius: 'Reroutes affected riders to nearest hub for mandatory cash handoff. New COD assignments paused.'
}
});
}
if (warningRiders.length) {
findings.push({
id: 'cash_exposure_warning',
skillId: 'skill_cash_exposure',
severity: 'warning',
title: `${warningRiders.length} rider${warningRiders.length === 1 ? '' : 's'} approaching cash limit (>${Math.round(warningPct * 100)}% of ₹${maxCash.toLocaleString('en-IN')})`,
why: `Total COD cash is approaching the safe threshold. Plan a hub handoff stop for these riders.`,
count: warningRiders.length,
rows: warningRiders.flatMap((rd) => rd.orders),
detail: warningRiders.slice(0, 8).map(
(rd) => `${rd.riderName} — ₹${rd.totalCash.toLocaleString('en-IN')} across ${rd.orders.length} orders`
)
});
}
return findings;
}
};

View File

@@ -0,0 +1,90 @@
// ==============================|| Skill: Doorstep Stall Rescuer ||============================== //
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const DoorstepStallSkill = {
id: 'skill_doorstep_stall',
name: 'Doorstep Stall Rescuer',
description: 'Flags riders who stamped arrival at consignee doorstep but have been stalled with no progress.',
category: 'rider_operations',
icon: 'MapPinOff',
defaultEnabled: true,
thresholds: {
arrivedStalledMin: {
label: 'Doorstep Stall Timeout',
description: 'Minutes after arriving at customer location before flagging as stalled.',
value: 20,
min: 10,
max: 60,
step: 5,
unit: 'min'
}
},
instructions: `
- When a rider is stalled at a delivery location, suggest verifying customer contact or initiating automated phone verification.
`,
tools: [
{
name: 'ping_stalled_rider',
description: 'Sends an in-app ping to check if the rider needs help with gate access or customer contact.',
parameters: {
type: 'object',
properties: {
milerId: { type: 'number', description: 'Miler ID to reach' },
message: { type: 'string', description: 'Optional prompt text' }
},
required: ['milerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'notify_miler',
milerId: args.milerId,
blastRadius: `Pings rider #${args.milerId} with doorstep assistance prompt.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const stallLimit = currentThresholds.arrivedStalledMin ?? 20;
const stalled = rows
.filter((r) => String(r.orderstatus || '').toLowerCase() === 'arrived' && r.reachedat)
.map((r) => ({ row: r, waitingMin: minutesBetween(r.reachedat, now) }))
.filter((m) => m.waitingMin !== null && m.waitingMin >= stallLimit)
.sort((a, b) => b.waitingMin - a.waitingMin);
if (!stalled.length) return [];
return [
{
id: 'stalled_at_door',
skillId: 'skill_doorstep_stall',
severity: 'critical',
title: `${stalled.length} rider${stalled.length === 1 ? '' : 's'} stalled at door for > ${stallLimit}m`,
why: 'Rider app reported arrival but delivery confirmation has not completed. Possible gate, address, or absent consignee issue.',
count: stalled.length,
rows: stalled.map((m) => m.row),
detail: stalled.slice(0, 8).map((m) => `${ref(m.row)} — ${m.row.ridername || 'Rider'} waiting ${m.waitingMin} min`),
proposal: {
label: 'Message waiting riders & support',
tool: 'notifyRider',
scope: stalled.filter((m) => m.row.userid).map((m) => m.row),
blastRadius: 'Sends an assistance check-in to stalled delivery riders.'
}
}
];
}
};

View File

@@ -0,0 +1,87 @@
// ==============================|| Skill: Fleet Load Balancer ||============================== //
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
export const FleetBalancerSkill = {
id: 'skill_fleet_balancer',
name: 'Fleet Load Balancer',
description: 'Prevents rider overload by flagging riders at maximum active parcel capacity while queue work waits.',
category: 'fleet_optimization',
icon: 'Scale',
defaultEnabled: true,
thresholds: {
riderActiveCap: {
label: 'Rider Active Capacity Cap',
description: 'Maximum concurrent active orders a single rider should carry simultaneously.',
value: 3,
min: 1,
max: 6,
step: 1,
unit: 'orders'
}
},
instructions: `
- Keep rider concurrent workload at or below the specified active cap to prevent cumulative delivery delays.
- Proactively redistribute un-picked orders from overloaded riders to available nearby fleet.
`,
tools: [
{
name: 'balance_rider_load',
description: 'Reallocates queued unpicked orders from a saturated rider to an available rider.',
parameters: {
type: 'object',
properties: {
fromMilerId: { type: 'number', description: 'Overloaded miler ID' },
toMilerId: { type: 'number', description: 'Available miler ID' }
},
required: ['fromMilerId', 'toMilerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'rebalance_workload',
fromMilerId: args.fromMilerId,
toMilerId: args.toMilerId,
blastRadius: `Transfers queued parcels from Rider #${args.fromMilerId} to Rider #${args.toMilerId}.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const activeCap = currentThresholds.riderActiveCap ?? 3;
const safeRows = Array.isArray(rows) ? rows : [];
const unassignedCount = safeRows.filter((r) => String(r?.orderstatus || '').toLowerCase() === 'pending').length;
const byRider = new Map();
safeRows.filter((r) => isOpen(r) && r.userid).forEach((r) => {
const key = String(r.userid);
if (!byRider.has(key)) byRider.set(key, { riderId: r.userid, name: r.ridername || `Rider ${r.userid}`, rows: [] });
byRider.get(key).rows.push(r);
});
const saturated = [...byRider.values()]
.filter((entry) => entry.rows.length >= activeCap)
.sort((a, b) => b.rows.length - a.rows.length);
if (!saturated.length || unassignedCount === 0) return [];
return [
{
id: 'rider_saturation',
skillId: 'skill_fleet_balancer',
severity: 'watch',
title: `${saturated.length} rider${saturated.length === 1 ? ' is' : 's are'} at capacity (≥${activeCap} jobs) while orders queue`,
why: `Riders carrying ${activeCap}+ in-flight jobs are capped and will not receive newly queued orders.`,
count: saturated.length,
rows: saturated.flatMap((entry) => entry.rows),
detail: saturated.slice(0, 8).map((entry) => `${entry.name} — ${entry.rows.length} concurrent orders`)
}
];
}
};

View File

@@ -0,0 +1,93 @@
// ==============================|| Skill: High-Value COD Escort ||============================== //
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const HighValueCodSkill = {
id: 'skill_high_value_cod',
name: 'High-Value Cash Guardian',
description: 'Audits large Cash-on-Delivery consignments to prevent loss, fraud, or unverified deliveries.',
category: 'loss_prevention',
icon: 'ShieldCheck',
// OFF by default (2026-09-29): the rows this rule reads carry no payment amount or payment mode: /admin/bookings does not preload bookingpayments.
// Enabled, it would read undefined on every row and report an all-clear board —
// the silent failure normalise.js warns about. Turn it on only once the data exists.
dataGap: 'no payment amount or payment mode: /admin/bookings does not preload bookingpayments',
defaultEnabled: false,
thresholds: {
codRiskThresholdAmount: {
label: 'High-Value COD Threshold (₹)',
description: 'Cash-on-Delivery amount above which an order is classified as high-risk cash asset.',
value: 3000,
min: 1000,
max: 20000,
step: 500,
unit: '₹'
}
},
instructions: `
- High-value COD consignments must require OTP verification at the doorstep and verified senior rider assignment.
`,
tools: [
{
name: 'enforce_otp_verification',
description: 'Flags an order as strictly requiring consignee OTP delivery confirmation before handover.',
parameters: {
type: 'object',
properties: {
bookingId: { type: 'number', description: 'Target booking ID' }
},
required: ['bookingId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'require_otp',
bookingId: args.bookingId,
blastRadius: `Enables mandatory OTP confirmation on mobile app for parcel #${args.bookingId}.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const minAmount = currentThresholds.codRiskThresholdAmount ?? 3000;
const safeRows = Array.isArray(rows) ? rows : [];
const highValueCod = safeRows.filter((r) => {
if (!isOpen(r)) return false;
const isCod = String(r.paymenttype || r.payment_mode || '').toLowerCase().includes('cod') ||
String(r.paymentstatus || '').toLowerCase() === 'pending';
const amount = Number(r.orderamount || r.totalamount || r.price || 0);
return isCod && amount >= minAmount;
});
if (!highValueCod.length) return [];
return [
{
id: 'high_value_cod_alert',
skillId: 'skill_high_value_cod',
severity: 'watch',
title: `${highValueCod.length} high-value COD consignment${highValueCod.length === 1 ? '' : 's'} (≥ ₹${minAmount.toLocaleString()}) active`,
why: `Large cash collection in transit requires mandatory OTP handover verification.`,
count: highValueCod.length,
rows: highValueCod,
detail: highValueCod.slice(0, 8).map((r) => {
const amt = Number(r.orderamount || r.totalamount || r.price || 0);
return `${ref(r)} — ₹${amt.toLocaleString()} (${r.orderstatus || 'active'})`;
}),
proposal: {
label: 'Verify OTP requirements',
tool: 'enforce_otp_verification',
scope: highValueCod,
blastRadius: 'Sets strict OTP delivery guard on high-value cash deliveries.'
}
}
];
}
};

View File

@@ -0,0 +1,105 @@
// ==============================|| Skill: Hub Congestion Agent ||============================== //
//
// Detects parcels that have been sitting at a hub for too long without
// being picked up by a rider. These represent blocked capacity at the hub
// and direct SLA risk for the customer. This is critical for hub operations
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const HUB_STATUSES = new Set(['pending', 'accepted']); // at hub, not yet picked
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const HubCongestionSkill = {
id: 'skill_hub_congestion',
name: 'Hub Congestion Agent',
description: 'Detects parcels sitting at a hub without rider pickup beyond the dwell threshold. Dispatches nearest idle rider.',
category: 'sla_management',
icon: 'ShieldAlert',
defaultEnabled: true,
thresholds: {
hubDwellMinutes: {
label: 'Hub Dwell Threshold',
description: 'Minutes a parcel can sit at hub without a rider pickup before raising a congestion alert.',
value: 45,
min: 15,
max: 120,
step: 5,
unit: 'min'
}
},
instructions: `
- Parcels sitting at a hub beyond the dwell threshold block capacity for the next batch.
- Identify the nearest idle rider in the same zone and dispatch immediately.
- Prefer riders with the fewest active orders to avoid overloading.
`,
tools: [
{
name: 'dispatch_hub_idle_parcels',
description: 'Proposes dispatching a nearby idle rider to collect stalled hub parcels.',
parameters: {
type: 'object',
properties: {
hubId: { type: 'string', description: 'Hub identifier where parcels are stalled' },
bookingIds: { type: 'array', items: { type: 'number' }, description: 'Booking IDs to dispatch' },
milerId: { type: 'number', description: 'Target idle rider user ID' }
},
required: ['bookingIds']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'dispatch_hub_parcels',
hubId: args.hubId || 'unknown',
bookingIds: args.bookingIds,
milerId: args.milerId,
blastRadius: `Dispatches rider to collect ${args.bookingIds?.length || 'stalled'} parcel(s) from hub. Updates parcel status to In-Transit.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const dwellLimit = currentThresholds.hubDwellMinutes ?? 45;
const findings = [];
const stalled = rows
.filter((r) => HUB_STATUSES.has(String(r?.orderstatus || '').toLowerCase()))
.map((r) => ({ row: r, dwellMin: minutesBetween(r.orderdate, now) }))
.filter((m) => m.dwellMin !== null && m.dwellMin >= dwellLimit)
.sort((a, b) => b.dwellMin - a.dwellMin);
if (stalled.length) {
findings.push({
id: 'hub_parcel_stalled',
skillId: 'skill_hub_congestion',
severity: 'critical',
title: `${stalled.length} parcel${stalled.length === 1 ? '' : 's'} stalled at hub for >${dwellLimit}m`,
why: 'Parcel has been at the hub beyond the acceptable dwell time with no rider assigned for pickup.',
count: stalled.length,
rows: stalled.map((m) => m.row),
detail: stalled.slice(0, 8).map((m) => `${ref(m.row)} — at hub for ${m.dwellMin} min`),
proposal: {
label: 'Dispatch idle rider to hub',
tool: 'dispatch_hub_idle_parcels',
scope: stalled.map((m) => m.row),
blastRadius: 'Assigns nearest idle zone rider to collect and dispatch all stalled parcels.'
}
});
}
return findings;
}
};

View File

@@ -0,0 +1,133 @@
// ==============================|| Skill: Late Dispatch Agent ||============================== //
//
// Flags orders that have been accepted/created but not yet dispatched to a rider
// within the acceptable dispatch window during business hours.
// Late dispatch at creation cascades into late delivery at the door.
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const PENDING_DISPATCH_STATUSES = new Set(['pending']);
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const LateDispatchSkill = {
id: 'skill_late_dispatch',
name: 'Late Dispatch Agent',
description: 'Flags orders accepted but sitting in queue without dispatch for too long. Triggers auto-assignment to prevent cascading delays.',
category: 'sla_management',
icon: 'ShieldAlert',
defaultEnabled: true,
thresholds: {
lateDispatchMinutes: {
label: 'Dispatch Deadline',
description: 'Minutes after order creation before flagging as late dispatch.',
value: 30,
min: 10,
max: 90,
step: 5,
unit: 'min'
},
criticalDispatchMinutes: {
label: 'Critical Dispatch Deadline',
description: 'Minutes after order creation before escalating to critical alert.',
value: 60,
min: 30,
max: 180,
step: 10,
unit: 'min'
}
},
instructions: `
- Orders waiting for dispatch beyond the threshold accumulate delay that can't be recovered at the doorstep.
- Auto-assign to the nearest available rider immediately.
- Prioritize orders with the earliest SLA deadlines when multiple are pending.
`,
tools: [
{
name: 'trigger_auto_dispatch',
description: 'Proposes auto-assigning late-pending orders to available riders in zone.',
parameters: {
type: 'object',
properties: {
bookingIds: { type: 'array', items: { type: 'number' }, description: 'Booking IDs requiring immediate dispatch' },
reason: { type: 'string', description: 'Dispatch urgency reason' }
},
required: ['bookingIds']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'auto_dispatch',
bookingIds: args.bookingIds,
reason: args.reason || 'Late dispatch threshold exceeded',
blastRadius: `Auto-assigns ${args.bookingIds?.length || 'late'} order(s) to nearest available zone riders. Rider notifications will be sent.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const lateThreshold = currentThresholds.lateDispatchMinutes ?? 30;
const criticalThreshold = currentThresholds.criticalDispatchMinutes ?? 60;
const findings = [];
const allPending = rows
.filter((r) => PENDING_DISPATCH_STATUSES.has(String(r?.orderstatus || '').toLowerCase()))
.map((r) => ({ row: r, waitMin: minutesBetween(r.orderdate, now) }))
.filter((m) => m.waitMin !== null && m.waitMin >= lateThreshold)
.sort((a, b) => b.waitMin - a.waitMin);
const critical = allPending.filter((m) => m.waitMin >= criticalThreshold);
const warning = allPending.filter((m) => m.waitMin >= lateThreshold && m.waitMin < criticalThreshold);
if (critical.length) {
findings.push({
id: 'late_dispatch_critical',
skillId: 'skill_late_dispatch',
severity: 'critical',
title: `${critical.length} order${critical.length === 1 ? '' : 's'} critically late for dispatch (>${criticalThreshold}m)`,
why: `Order has been sitting in queue for over ${criticalThreshold} minutes with no rider assigned. Delivery SLA is at severe risk.`,
count: critical.length,
rows: critical.map((m) => m.row),
detail: critical.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.waitMin} min for dispatch`),
proposal: {
label: 'Auto-dispatch to available riders',
tool: 'trigger_auto_dispatch',
scope: critical.map((m) => m.row),
blastRadius: 'Immediately assigns nearest available riders. Overrides manual queue priority.'
}
});
}
if (warning.length) {
findings.push({
id: 'late_dispatch_warning',
skillId: 'skill_late_dispatch',
severity: 'warning',
title: `${warning.length} order${warning.length === 1 ? '' : 's'} approaching dispatch deadline (>${lateThreshold}m)`,
why: `Order has been waiting ${lateThreshold}+ minutes for rider assignment. Act now to prevent SLA breach.`,
count: warning.length,
rows: warning.map((m) => m.row),
detail: warning.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.waitMin} min`),
proposal: {
label: 'Dispatch now',
tool: 'trigger_auto_dispatch',
scope: warning.map((m) => m.row),
blastRadius: 'Assigns available riders to pending orders based on zone proximity.'
}
});
}
return findings;
}
};

View File

@@ -0,0 +1,91 @@
// ==============================|| Skill: Rider Battery & Device Safety ||============================== //
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const RiderBatterySafetySkill = {
id: 'skill_rider_battery_safety',
name: 'Rider Device & SOS Safety',
description: 'Monitors rider device telemetry to prevent unreachability due to low battery during active deliveries.',
category: 'rider_safety',
icon: 'BatteryWarning',
// OFF by default (2026-09-29): the rows this rule reads carry no battery level: it lives on the rider profile (milerprofiles.batterypercentage), not on booking rows.
// Enabled, it would read undefined on every row and report an all-clear board —
// the silent failure normalise.js warns about. Turn it on only once the data exists.
dataGap: 'no battery level: it lives on the rider profile (milerprofiles.batterypercentage), not on booking rows',
defaultEnabled: false,
thresholds: {
criticalBatteryPercent: {
label: 'Critical Battery Level (%)',
description: 'Threshold below which rider device is considered at risk of shutdown.',
value: 15,
min: 5,
max: 30,
step: 5,
unit: '%'
}
},
instructions: `
- When a rider has critical battery while carrying in-transit orders, advise charging or offloading remaining pending pickups.
`,
tools: [
{
name: 'alert_low_battery_rider',
description: 'Notifies rider to connect portable charger or report to nearest hub.',
parameters: {
type: 'object',
properties: {
milerId: { type: 'number', description: 'Target miler user ID' }
},
required: ['milerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'battery_alert',
milerId: args.milerId,
blastRadius: `Sends device power warning notification to Rider #${args.milerId}.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const minBattery = currentThresholds.criticalBatteryPercent ?? 15;
const safeRows = Array.isArray(rows) ? rows : [];
const atRisk = safeRows.filter((r) => {
if (!isOpen(r) || !r.userid) return false;
const battery = Number(r.battery_percentage || r.battery_level || r.battery || 100);
return battery > 0 && battery <= minBattery;
});
if (!atRisk.length) return [];
return [
{
id: 'rider_critical_battery',
skillId: 'skill_rider_battery_safety',
severity: 'warning',
title: `${atRisk.length} active delivery under low rider device battery (≤ ${minBattery}%)`,
why: `Rider phone is nearing shutdown, risking lost GPS telemetry and customer communication failure.`,
count: atRisk.length,
rows: atRisk,
detail: atRisk.slice(0, 8).map((r) => {
const bat = r.battery_percentage || r.battery_level || r.battery || 'Low';
return `${ref(r)} — ${r.ridername || 'Rider'} (${bat}% battery)`;
}),
proposal: {
label: 'Send power alert',
tool: 'alert_low_battery_rider',
scope: atRisk,
blastRadius: 'Sends charging prompt to rider.'
}
}
];
}
};

View File

@@ -0,0 +1,157 @@
// ==============================|| Skill: SLA Breach Guardian ||============================== //
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const IN_FLIGHT_STATUSES = new Set(['picked', 'active']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const SlaGuardianSkill = {
id: 'skill_sla_guardian',
name: 'SLA Breach Guardian',
description: 'Monitors promised customer delivery ETAs and flags both breached and imminent SLA violations.',
category: 'sla_management',
icon: 'ShieldAlert',
defaultEnabled: true,
thresholds: {
slaRiskWindowMin: {
label: 'At-Risk Warning Window',
description: 'Minutes before promised delivery window to flag an un-picked parcel as at-risk.',
value: 45,
min: 15,
max: 90,
step: 5,
unit: 'min'
},
unassignedAgingMin: {
label: 'Unassigned Aging Threshold',
description: 'Minutes an order can remain pending with no assigned rider before raising an alarm.',
value: 60,
min: 15,
max: 120,
step: 5,
unit: 'min'
}
},
instructions: `
- When orders exceed promised delivery ETA or are within the risk window without being in flight, prioritize immediate reassignment.
- Select nearby available riders with high historical on-time fulfillment rates.
`,
tools: [
{
name: 'reassign_sla_critical_order',
description: 'Proposes transferring an SLA-at-risk order to an optimal nearby rider.',
parameters: {
type: 'object',
properties: {
bookingId: { type: 'number', description: 'Booking ID to reassign' },
targetMilerId: { type: 'number', description: 'Target miler user ID' },
reason: { type: 'string', description: 'Justification' }
},
required: ['bookingId', 'targetMilerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'reassign_miler',
bookingId: args.bookingId,
milerId: args.targetMilerId,
reason: args.reason || 'SLA risk mitigation',
blastRadius: `Reassigns parcel #${args.bookingId} and updates customer ETA tracking.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const riskWindow = currentThresholds.slaRiskWindowMin ?? 45;
const agingWindow = currentThresholds.unassignedAgingMin ?? 60;
const findings = [];
// 1. Breached SLA
const breached = rows
.filter((r) => isOpen(r) && r.expecteddeliverytime)
.map((r) => ({ row: r, overdueMin: minutesBetween(r.expecteddeliverytime, now) }))
.filter((m) => m.overdueMin !== null && m.overdueMin > 0)
.sort((a, b) => b.overdueMin - a.overdueMin);
if (breached.length) {
findings.push({
id: 'sla_breached',
skillId: 'skill_sla_guardian',
severity: 'critical',
title: `${breached.length} ${breached.length === 1 ? 'parcel is' : 'parcels are'} past promised delivery time`,
why: 'Estimated delivery time on service option has elapsed and parcel is not yet delivered.',
count: breached.length,
rows: breached.map((m) => m.row),
detail: breached.slice(0, 8).map((m) => `${ref(m.row)} — ${m.overdueMin} min overdue, status: ${m.row.orderstatus}`),
proposal: {
label: 'Notify assigned riders',
tool: 'notifyRider',
scope: breached.filter((m) => m.row.userid).map((m) => m.row),
blastRadius: 'Sends an urgent priority notification to the assigned rider.'
}
});
}
// 2. At-Risk SLA
const atRisk = rows
.filter((r) => isOpen(r) && r.expecteddeliverytime && !IN_FLIGHT_STATUSES.has(String(r.orderstatus).toLowerCase()))
.map((r) => ({ row: r, dueInMin: minutesBetween(now, r.expecteddeliverytime) }))
.filter((m) => m.dueInMin !== null && m.dueInMin > 0 && m.dueInMin <= riskWindow)
.sort((a, b) => a.dueInMin - b.dueInMin);
if (atRisk.length) {
findings.push({
id: 'sla_at_risk',
skillId: 'skill_sla_guardian',
severity: 'warning',
title: `${atRisk.length} parcel${atRisk.length === 1 ? '' : 's'} due within ${riskWindow}m and not yet moving`,
why: 'Inside the delivery promise window but parcel pickup has not begun.',
count: atRisk.length,
rows: atRisk.map((m) => m.row),
detail: atRisk.slice(0, 8).map((m) => `${ref(m.row)} — due in ${m.dueInMin} min, status: ${m.row.orderstatus}`)
});
}
// 3. Unassigned Aging
const aging = rows
.filter((r) => String(r.orderstatus || '').toLowerCase() === 'pending')
.map((r) => ({ row: r, ageMin: minutesBetween(r.orderdate, now) }))
.filter((m) => m.ageMin !== null && m.ageMin >= agingWindow)
.sort((a, b) => b.ageMin - a.ageMin);
if (aging.length) {
findings.push({
id: 'unassigned_aging',
skillId: 'skill_sla_guardian',
severity: 'warning',
title: `${aging.length} booking${aging.length === 1 ? '' : 's'} unassigned for > ${agingWindow}m`,
why: 'Order has been sitting in queue with no assigned miler.',
count: aging.length,
rows: aging.map((m) => m.row),
detail: aging.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.ageMin} min`),
proposal: {
label: 'Auto-assign available rider',
tool: 'assignMiler',
scope: aging.map((m) => m.row),
blastRadius: 'Initiates dispatch matching across idle zone riders.'
}
});
}
return findings;
}
};

View File

@@ -0,0 +1,10 @@
export { SkillRegistry, DEFAULT_SKILLS } from './SkillRegistry';
export { useSkillRegistrySync } from './useSkillRegistrySync';
export { SlaGuardianSkill } from './definitions/SlaGuardianSkill';
export { DoorstepStallSkill } from './definitions/DoorstepStallSkill';
export { FleetBalancerSkill } from './definitions/FleetBalancerSkill';
export { HighValueCodSkill } from './definitions/HighValueCodSkill';
export { RiderBatterySafetySkill } from './definitions/RiderBatterySafetySkill';
export { HubCongestionSkill } from './definitions/HubCongestionSkill';
export { LateDispatchSkill } from './definitions/LateDispatchSkill';
export { CashExposureSkill } from './definitions/CashExposureSkill';

View File

@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { useAiSkills } from '@/lib/doormileHooks';
import { SkillRegistry } from './SkillRegistry';
/**
* Keeps the in-memory SkillRegistry in step with the backend agent registry.
*
* Mounted once, in the console shell, so the Exceptions banner and the chat's
* "what needs attention" briefing both run on the same, tuned settings. A
* change saved in Settings → Skills & Tools invalidates the registry query,
* this refetches, and every subscriber re-evaluates.
*
* `enabled` is false for partner-tenant logins: the registry is Doormile-staff
* only (the backend answers 403), so they run on code defaults, which the
* banner states.
*/
export function useSkillRegistrySync(enabled = true) {
const { data, isError } = useAiSkills({ enabled, retry: false });
useEffect(() => {
if (data) SkillRegistry.applyRegistryConfig(data);
}, [data]);
useEffect(() => {
if (isError || !enabled) SkillRegistry.clearRegistryConfig();
}, [isError, enabled]);
}

View File

@@ -0,0 +1,75 @@
/**
* Who may onboard a new client (create a tenant and its console login).
*
* The server is the real gate — POST /admin/clients/onboard answers 403 to
* anyone outside CLIENT_ONBOARDING_OWNERS (default admin@doormile.com), and
* only for Doormile staff with roleid 1. This list only decides whether the
* console SHOWS the page and its links, so keep it matching the server's.
*/
const DEFAULT_OWNERS = 'admin@doormile.com';
export const CLIENT_ONBOARDING_OWNERS = String(import.meta.env?.VITE_CLIENT_ONBOARDING_OWNERS || DEFAULT_OWNERS)
.split(',')
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
/**
* @param {any} user the signed-in console user (AuthContext)
* @param {boolean} isClient true for a partner-tenant login
*/
export function canOnboardClients(user, isClient) {
if (!user || isClient) return false;
const email = String(user.email || '').trim().toLowerCase();
const role = String(user.role || '').toLowerCase();
return CLIENT_ONBOARDING_OWNERS.includes(email) && (role === 'admin' || String(user.roleid) === '1');
}
/** A random password an operator can hand over: 14 chars, no look-alikes. */
export function generateClientPassword(length = 14) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789@#%&*';
const bytes = new Uint32Array(length);
globalThis.crypto.getRandomValues(bytes);
let out = '';
for (let i = 0; i < length; i += 1) out += chars[bytes[i] % chars.length];
// Guarantee a digit and a letter, so a generated password is never all one class.
if (!/\d/.test(out)) out = `${out.slice(0, -1)}7`;
if (!/[a-z]/i.test(out)) out = `k${out.slice(1)}`;
return out;
}
/** Client-side mirror of the server's validation, for inline errors. */
export function validateOnboarding(form) {
const errors = {};
const phone = String(form.phone || '').replace(/\D/g, '');
if (String(form.companyname || '').trim().length < 2) errors.companyname = 'Enter the company name';
if (String(form.contactname || '').trim().length < 2) errors.contactname = "Enter the contact person's name";
if (!/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(String(form.email || '').trim())) errors.email = 'Enter a valid email address';
if (!/^[6-9]\d{9}$/.test(phone)) errors.phone = 'Enter a 10-digit mobile number';
if (!form.applocationid) errors.applocationid = "Choose the client's operating city";
const pw = String(form.password || '');
if (pw.length < 8) errors.password = 'At least 8 characters';
else if (pw.length > 72) errors.password = 'At most 72 characters';
else if (pw.toLowerCase() === String(form.email || '').trim().toLowerCase() || pw === phone) {
errors.password = 'Must not be the email or the phone number';
}
if (form.confirm !== pw) errors.confirm = 'The passwords do not match';
return errors;
}
/**
* Why onboarding cannot run, from the failed GET /admin/clients/cities. The
* page shows this instead of an empty city list: the likeliest cause is a
* console pointed at an API that does not have the onboarding endpoints yet.
*/
export function onboardingUnavailableMessage(err) {
const status = err?.response?.status;
if (status === 404) {
return 'This API server does not have client onboarding yet. The backend changes that add it need to be deployed first.';
}
if (status === 403) {
return 'The server refused this login. Client onboarding is only for the admin@doormile.com account, signed in as Doormile staff.';
}
if (status === 401) return 'Your session has expired. Sign in again.';
if (!err?.response) return 'Could not reach the API server. Check your connection and try again.';
return err.response.data?.message || `The server answered ${status}. Try again shortly.`;
}

View File

@@ -49,6 +49,11 @@ export const calculateDrivingRoute = async (origin, destination) => {
const data = await response.json();
if (data.routes && data.routes.length > 0) {
const route = data.routes[0];
// `distance` stays whole km: it is what pricing and bulk upload bill on,
// and changing its precision would change invoices. `meters` is the
// unrounded road length, for display only — a 120 m drop rounds to
// 0 km here and must not be shown as "0 km" or floored to "1 km".
const meters = Math.max(0, Math.round(route.distance));
const distanceKm = Math.round(route.distance / 1000);
const durationMin = Number.isFinite(route.duration) ? Math.round(route.duration / 60) : null;
// OSRM geojson coordinates are [lng, lat] -> convert to Leaflet [lat, lng]
@@ -56,6 +61,7 @@ export const calculateDrivingRoute = async (origin, destination) => {
lastRouteDurationMin = durationMin;
return {
distance: Math.max(0, distanceKm),
meters,
minutes: durationMin,
polyline: polyline.length > 0 ? polyline : [[lat1, lon1], [lat2, lon2]],
resolved: true
@@ -81,6 +87,7 @@ export const calculateDrivingRoute = async (origin, destination) => {
lastRouteDurationMin = null;
return {
distance: aerialDistance,
meters: Math.round(R * c * 1.3 * 1000),
minutes: null,
polyline: [[lat1, lon1], [lat2, lon2]],
resolved: true
@@ -95,6 +102,30 @@ export const calculateDrivingDistance = async (origin, destination) => {
return res.distance;
};
/**
* Formats a road length for display: metres under 1 km (to the nearest 10 m),
* km with one decimal above it. Returns '—' when the length is unknown.
* @param {number} meters
*/
export const formatRouteDistance = (meters) => {
if (meters === null || meters === undefined) return '—';
const m = Number(meters);
if (!Number.isFinite(m) || m < 0) return '—';
const tens = Math.round(m / 10) * 10;
if (tens < 1000) return `${tens} m`;
return `${Number((m / 1000).toFixed(1))} km`;
};
/**
* Formats a drive time for display. OSRM reports seconds; a sub-minute leg
* rounds to 0 and must read "< 1 min", not fall through to a default.
* @param {number|null} minutes
*/
export const formatRouteDuration = (minutes) => {
if (minutes === null || minutes === undefined || !Number.isFinite(Number(minutes))) return '—';
return Number(minutes) < 1 ? '< 1 min' : `${minutes} mins`;
};
/**
* Calculates total charge based on distance and pricing tier
*/

View File

@@ -67,6 +67,14 @@ export const KEYS = {
carrierPricing: (page, limit) => ['doormile', 'carrier-pricing', page, limit],
appLocations: ['doormile', 'app-locations'],
// AI agent registry. One prefix, so a write invalidates agents, skills,
// tools and the audit together — a skill toggle changes an agent's counts.
aiRegistry: ['doormile', 'ai'],
aiAgents: ['doormile', 'ai', 'agents'],
aiSkills: ['doormile', 'ai', 'skills'],
aiTools: ['doormile', 'ai', 'tools'],
aiAudit: ['doormile', 'ai', 'audit'],
};
/* ── Shared helpers ───────────────────────────────────────────────────────── */
@@ -179,6 +187,23 @@ export const useTenant = (id, options) =>
export const useCreateTenant = () =>
useDoormileMutation({ mutationFn: api.createAdminTenant, invalidates: [KEYS.tenants], successMessage: 'Client created' });
export const useOnboardedClients = (options) =>
useQuery({ queryKey: [...KEYS.tenants, 'onboarded'], queryFn: api.getOnboardedClients, staleTime: 30_000, ...options });
export const useOnboardingCities = (options) =>
useQuery({ queryKey: [...KEYS.tenants, 'onboarding-cities'], queryFn: api.getOnboardingCities, staleTime: 300_000, ...options });
// No toasts: the page shows its own success panel and inline errors.
export const useOnboardClient = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.onboardClient,
onSuccess: (result) => {
if (result?.success !== false) queryClient.invalidateQueries({ queryKey: KEYS.tenants });
},
});
};
export const useUpdateTenant = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updateAdminTenant(id, data),
@@ -792,3 +817,57 @@ export const useOrderPercentages = (startdate, enddate, options) =>
staleTime: 30_000,
...options,
});
/* ── AI agent registry (Settings → Skills & Tools) ────────────────────────── */
export const useAiAgents = (options) =>
useQuery({ queryKey: KEYS.aiAgents, queryFn: api.getAiAgents, staleTime: 30_000, ...options });
export const useAiSkills = (options) =>
useQuery({ queryKey: KEYS.aiSkills, queryFn: api.getAiSkills, staleTime: 30_000, ...options });
export const useAiTools = (options) =>
useQuery({ queryKey: KEYS.aiTools, queryFn: api.getAiTools, staleTime: 5 * 60_000, ...options });
export const useUpdateAiSkill = () =>
useDoormileMutation({
mutationFn: ({ id, patch }) => api.updateAiSkill(id, patch),
invalidates: [KEYS.aiRegistry],
successMessage: 'Skill updated',
});
export const useCreateAiSkill = () =>
useDoormileMutation({
mutationFn: (skill) => api.createAiSkill(skill),
invalidates: [KEYS.aiRegistry],
successMessage: 'Skill registered',
});
export const useUpdateAiAgent = () =>
useDoormileMutation({
mutationFn: ({ id, patch }) => api.updateAiAgent(id, patch),
invalidates: [KEYS.aiRegistry],
successMessage: 'Agent updated',
});
// Read-only, and it changes as agents work: refetched every minute while open.
export const useAiInsights = (days = 7, options) =>
useQuery({
queryKey: [...KEYS.aiRegistry, 'insights', days],
queryFn: () => api.getAiInsights(days),
staleTime: 30_000,
refetchInterval: 60_000,
...options,
});
// A playground run. No toasts and no invalidation: it changes nothing, and the
// Test tab shows its own result or error inline.
export const useRunAiPlayground = () => useMutation({ mutationFn: api.runAiPlayground });
export const useAiDecisions = (params = {}, options) =>
useQuery({
queryKey: [...KEYS.aiRegistry, 'decisions', params],
queryFn: () => api.getAiDecisions(params),
staleTime: 30_000,
...options,
});

View File

@@ -62,7 +62,7 @@ export default function Login() {
if (!validate()) return;
setStatus('loading');
const result = await login(form.email.trim(), form.password);
const result = await login(form.email.trim().toLowerCase(), form.password);
if (!result.success) {
setStatus('idle');

View File

@@ -2,12 +2,9 @@ import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { AnimatePresence, motion } from 'framer-motion';
import {
Activity,
CheckCircle2,
ChevronDown,
ChevronUp,
Copy,
ExternalLink,
Radio,
RefreshCw,
Server,
@@ -19,7 +16,7 @@ import {
import toast from 'react-hot-toast';
import { cn } from '@/lib/utils';
import { TRIGGER, agentById } from '@/lib/agentNetwork';
import { agentById } from '@/lib/agentNetwork';
const UI_ENDPOINT = 'https://app.doormile.com/doormile/agents';
const WS_ENDPOINT = 'wss://app.doormile.com/doormile/agents/stream';

View File

@@ -5,20 +5,14 @@ import gsap from 'gsap';
import {
Activity,
AlertTriangle,
ArrowRight,
Box,
CheckCircle2,
Cpu,
Layers,
Package,
Play,
Radio,
RotateCcw,
Server,
ShieldAlert,
ShieldCheck,
Sparkles,
Zap,
} from 'lucide-react';
import { cn } from '@/lib/utils';

View File

@@ -4,16 +4,11 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import {
ArrowLeft,
ArrowRight,
CheckCircle2,
ChevronRight,
ExternalLink,
Layers,
Pause,
Play,
RotateCcw,
ShieldCheck,
Sparkles,
Zap,
} from 'lucide-react';
import { cn } from '@/lib/utils';

View File

@@ -1,34 +1,13 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { AnimatePresence, motion } from 'framer-motion';
import {
Activity,
AlertTriangle,
ArrowUpRight,
Bot,
CheckCircle2,
ChevronRight,
ExternalLink,
Layers,
Radio,
RefreshCw,
Server,
ShieldAlert,
ShieldCheck,
Terminal,
Zap,
} from 'lucide-react';
import { PageHeader } from '@/components/ds';
import { cn } from '@/lib/utils';
import {
AGENTS,
LOG_ERROR_LINES,
LOG_TOTAL_LINES,
RUN_LOG,
agentById,
networkStats,
} from '@/lib/agentNetwork';
import { SNAPSHOT_AT, SNAPSHOT_LABEL, networkStats } from '@/lib/agentNetwork';
import AgentFlowVisualizer from './AgentFlowVisualizer';
import AgentInspectorDrawer from './AgentInspectorDrawer';
import './Agents.css';
@@ -38,7 +17,16 @@ export default function Agents() {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [activeLogModal, setActiveLogModal] = useState(null);
// Live Activity Stream (3–5 recent events, Requirement: LIVE ACTIVITY)
// Counts come from the snapshot in lib/agentNetwork.js, not from a feed.
// The status bar used to hard-code them; now they at least match the record
// the rest of the page is drawn from, and the bar says how old that record is.
const stats = networkStats();
const idle = stats.total - stats.working - stats.degraded;
// SAMPLE activity. The seed rows and everything AgentFlowVisualizer appends
// come from its playback loop, not from AI_engine. `AI_engine` exposes no
// endpoint this console can read (see lib/agentNetwork.js), so the panel is
// labelled as a simulation rather than as live.
const [liveActivities, setLiveActivities] = useState([
{
id: 1,
@@ -94,14 +82,19 @@ export default function Agents() {
═══════════════════════════════════════════════════════════════════ */}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/80 bg-surface px-4 py-2.5 shadow-2xs">
{/* Left: Identity */}
<div className="flex items-center gap-2.5">
<span className="flex h-2.5 w-2.5 rounded-full bg-emerald-500 animate-pulse" />
<div className="flex flex-wrap items-center gap-2.5">
{/* Static, not pulsing: nothing on this page is a live feed. */}
<span className="flex h-2.5 w-2.5 rounded-full bg-slate-400" />
<h1 className="font-heading text-sm sm:text-base font-bold text-ink-1">
Doormile AI Agent Network
</h1>
<span className="text-caption text-ink-4 hidden md:inline">
· Real-Time Workflow Visualizer
</span>
<time
dateTime={SNAPSHOT_AT}
className="rounded-full border border-border bg-surface-subtle px-2 py-0.5 text-[11px] font-semibold text-ink-3"
title="Figures are read from the AI_engine run log, not streamed. They do not update."
>
Snapshot · {SNAPSHOT_LABEL}
</time>
</div>
{/* Right: Clean Status Indicators */}
@@ -109,19 +102,19 @@ export default function Agents() {
{/* 1 Processing */}
<div className="flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/40 px-2.5 py-0.5 text-emerald-800 dark:text-emerald-300 font-semibold">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
<span>1 Processing</span>
<span>{stats.working} Processing</span>
</div>
{/* 1 Warning */}
<div className="flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40 px-2.5 py-0.5 text-amber-800 dark:text-amber-300 font-semibold">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
<span>1 Warning</span>
<span>{stats.degraded} Warning</span>
</div>
{/* 7 Idle */}
<div className="flex items-center gap-1.5 rounded-full border border-border bg-surface-subtle px-2.5 py-0.5 text-ink-3 font-semibold">
<span className="h-1.5 w-1.5 rounded-full bg-slate-400" />
<span>7 Idle</span>
<span>{idle} Idle</span>
</div>
{/* 🔒 Autonomy Locked */}
@@ -130,7 +123,9 @@ export default function Agents() {
title="Autonomy locked: AI observes and recommends, but cannot independently alter production systems."
>
<ShieldCheck className="h-3 w-3 text-indigo-600 dark:text-indigo-400" />
<span>Autonomy: 0 / 8</span>
{/* Denominator is the agents that HAVE an autonomy gate (Dispatch,
Exception, Express). It read "0 / 8", which implied eight gates. */}
<span>Autonomy gates on: {stats.autonomyOn} / {stats.gated}</span>
</div>
</div>
</div>
@@ -153,11 +148,11 @@ export default function Agents() {
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-ink-2" />
<h2 className="font-heading text-xs font-bold uppercase tracking-wider text-ink-1">
Live Activity
Sample Activity
</h2>
</div>
<span className="text-[10.5px] font-mono text-ink-4">
Latest events
<span className="text-[10.5px] font-mono text-amber-700">
Simulation · not live data
</span>
</div>

View File

@@ -0,0 +1,381 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Building2, Copy, Eye, EyeOff, KeyRound, ShieldCheck, UserPlus } from 'lucide-react';
import {
Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
BreadcrumbSeparator, Button, DataTable, EmptyState, Field, Input, PageHeader, Stack,
StatusBadge, Surface, Switch,
} from '@/components/ds';
import { inputVariants } from '@/components/ui/input';
import { useAuth } from '@/lib/AuthContext';
import { useOnboardClient, useOnboardedClients, useOnboardingCities } from '@/lib/doormileHooks';
import {
canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateOnboarding,
} from '@/lib/clientOnboarding';
/**
* Client onboarding — register a new client and give them a console login.
*
* One submit creates, in a single server transaction, the tenant (the client
* company), its console login (doormile_auth, scoped to that tenant) and its
* appusers row. The client then signs in at the normal console login and sees
* only their own data.
*
* Restricted to the onboarding owner login (admin@doormile.com). The server
* enforces that; this page only hides itself from everyone else.
*/
const EMPTY = {
companyname: '',
contactname: '',
email: '',
phone: '',
applocationid: '',
password: '',
confirm: '',
requiredeliveryotp: false,
};
const SERVER_FIELD = [
['company name', 'companyname'],
['contact person', 'contactname'],
['email', 'email'],
['mobile', 'phone'],
['password', 'password'],
['operating city', 'applocationid'],
];
/** Put a server message under the field it is about, when it names one. */
function serverFieldOf(message) {
const m = String(message || '').toLowerCase();
const hit = SERVER_FIELD.find(([needle]) => m.includes(needle));
return hit ? hit[1] : null;
}
function OnboardedList() {
const { data = [], isLoading } = useOnboardedClients();
const columns = [
{ key: 'tenantname', header: 'Client', accessor: (r) => r.tenantname },
{ key: 'loginemail', header: 'Console login', accessor: (r) => r.loginemail },
{ key: 'primarycontact', header: 'Contact', accessor: (r) => r.primarycontact || '—' },
{ key: 'status', header: 'Status', cell: (r) => <StatusBadge status={r.status} size="sm" /> },
{
key: 'logincreatedat',
header: 'Login created',
accessor: (r) => (r.logincreatedat ? new Date(r.logincreatedat).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'),
},
];
return (
<Surface radius="xl" padding="lg">
<h2 className="text-body font-semibold text-ink-1 mb-3">Clients with a console login</h2>
<DataTable
columns={columns}
rows={data}
getRowId={(row, i) => `${row.tenantid}-${row.loginemail}-${i}`}
loading={isLoading}
emptyState={<EmptyState icon={Building2} size="sm" title="No client logins yet" />}
/>
</Surface>
);
}
export default function ClientOnboarding() {
const navigate = useNavigate();
const { user, isClient } = useAuth();
const allowed = canOnboardClients(user, isClient);
const onboard = useOnboardClient();
const citiesQ = useOnboardingCities({ enabled: allowed, retry: false });
const locations = citiesQ.data || [];
// When the city list cannot be read, onboarding cannot work on this server
// either (same backend, same gate) — say why instead of an empty dropdown.
const unavailable = citiesQ.isError ? onboardingUnavailableMessage(citiesQ.error) : '';
const cities = useMemo(() => locations.filter((l) => Number(l.applocationid) > 0), [locations]);
const [form, setForm] = useState(EMPTY);
const [errors, setErrors] = useState({});
const [formError, setFormError] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [created, setCreated] = useState(null); // { tenant, login, password }
const [copied, setCopied] = useState(false);
const set = (key) => (value) => {
setForm((f) => ({ ...f, [key]: value }));
setErrors((e) => ({ ...e, [key]: undefined }));
};
if (!allowed) {
return (
<Stack space="lg">
<EmptyState
icon={ShieldCheck}
title="Client onboarding is restricted"
description="Only the designated onboarding account can register new clients."
action={
isClient
? { label: 'Go to home', onClick: () => navigate('/doormile/home') }
: { label: 'Back to clients', onClick: () => navigate('/doormile/tenants') }
}
/>
</Stack>
);
}
const submit = (event) => {
event.preventDefault();
setFormError('');
const nextErrors = validateOnboarding(form);
setErrors(nextErrors);
if (Object.keys(nextErrors).length) return;
const password = form.password;
onboard.mutate(
{
companyname: form.companyname.trim(),
contactname: form.contactname.trim(),
email: form.email.trim().toLowerCase(),
phone: form.phone.replace(/\D/g, ''),
password,
applocationid: Number(form.applocationid),
requiredeliveryotp: form.requiredeliveryotp,
},
{
onSuccess: (res) => {
if (res?.success === false) {
setFormError(res.message || 'The client was not created.');
return;
}
setCreated({ ...res.data, password });
setForm(EMPTY);
setCopied(false);
},
onError: (err) => {
const status = err?.response?.status;
const message = err?.response?.data?.message;
if (status === 403) {
setFormError('Your login is not allowed to onboard clients.');
return;
}
const field = serverFieldOf(message);
if (field && message) setErrors((e) => ({ ...e, [field]: message }));
else setFormError(message || 'The client could not be created. Nothing was saved.');
},
}
);
};
const copyLogin = async () => {
if (!created) return;
const text = [
`Doormile console: ${window.location.origin}/login`,
`Email: ${created.login.email}`,
`Password: ${created.password}`,
].join('\n');
try {
await navigator.clipboard.writeText(text);
setCopied(true);
} catch {
setCopied(false);
}
};
return (
<Stack space="lg">
<PageHeader
breadcrumb={
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/doormile/tenants">Clients</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Onboard client</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
}
actions={
<Button variant="outline" onClick={() => navigate('/doormile/tenants')}>
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
</Button>
}
/>
{created && (
<Alert tone="success" title={`${created.tenant.tenantname} is onboarded`} role="status">
<p>
Console login <span className="font-semibold">{created.login.email}</span> is ready. They sign in at the
usual console login and see only their own orders.
</p>
<p className="mt-1">
This is the only time the password is shown — share it with the client securely.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={copyLogin}>
<Copy className="mr-1.5 h-4 w-4" /> {copied ? 'Copied' : 'Copy login details'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setCreated(null)}>
Onboard another
</Button>
</div>
</Alert>
)}
<form onSubmit={submit} noValidate>
<Surface radius="xl" padding="lg">
<div className="mb-5 flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-tint text-brand">
<UserPlus className="h-5 w-5" />
</div>
<div>
<h1 className="text-h4 font-semibold text-ink-1">Onboard a new client</h1>
<p className="text-body-sm text-ink-3">
Creates the client and their console login together. If any part fails, nothing is saved.
</p>
</div>
</div>
{unavailable && (
<Alert
tone="warning"
className="mb-4"
role="alert"
title="Client onboarding is not available right now"
action={
<Button size="sm" variant="outline" onClick={() => citiesQ.refetch()}>
Try again
</Button>
}
>
{unavailable}
</Alert>
)}
{formError && (
<Alert tone="destructive" className="mb-4" role="alert">
{formError}
</Alert>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Company name" required error={errors.companyname} className="sm:col-span-2">
<Input
value={form.companyname}
onChange={(e) => set('companyname')(e.target.value)}
placeholder="Peelamedu Provisions Pvt Ltd"
maxLength={120}
/>
</Field>
<Field label="Contact person" required error={errors.contactname}>
<Input
value={form.contactname}
onChange={(e) => set('contactname')(e.target.value)}
placeholder="Priya Raman"
maxLength={80}
/>
</Field>
<Field label="Mobile number" required error={errors.phone}>
<Input
value={form.phone}
onChange={(e) => set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))}
placeholder="9876543210"
inputMode="numeric"
/>
</Field>
<Field label="Login email" required error={errors.email} hint="The client signs in to the console with this">
<Input
type="email"
value={form.email}
onChange={(e) => set('email')(e.target.value)}
placeholder="ops@client.com"
autoComplete="off"
/>
</Field>
<Field label="Operating city" required error={errors.applocationid}>
<select
value={form.applocationid}
onChange={(e) => set('applocationid')(e.target.value)}
className={inputVariants()}
>
<option value="">
{citiesQ.isLoading ? 'Loading cities…' : unavailable ? 'Cities could not be loaded' : cities.length ? 'Choose a city' : 'No operating cities found'}
</option>
{cities.map((c) => (
<option key={c.applocationid} value={c.applocationid}>
{c.applocationname}
</option>
))}
</select>
</Field>
<Field label="Password" required error={errors.password} hint="At least 8 characters" htmlFor="onboard-password">
<Input
id="onboard-password"
type={showPassword ? 'text' : 'password'}
value={form.password}
onChange={(e) => set('password')(e.target.value)}
autoComplete="new-password"
maxLength={72}
/>
</Field>
<Field label="Confirm password" required error={errors.confirm}>
<Input
type={showPassword ? 'text' : 'password'}
value={form.confirm}
onChange={(e) => set('confirm')(e.target.value)}
autoComplete="new-password"
maxLength={72}
/>
</Field>
<div className="flex flex-wrap items-center gap-2 sm:col-span-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const pw = generateClientPassword();
setForm((f) => ({ ...f, password: pw, confirm: pw }));
setErrors((e) => ({ ...e, password: undefined, confirm: undefined }));
setShowPassword(true);
}}
>
<KeyRound className="mr-1.5 h-4 w-4" /> Generate password
</Button>
<Button type="button" size="sm" variant="ghost" onClick={() => setShowPassword((s) => !s)}>
{showPassword ? <EyeOff className="mr-1.5 h-4 w-4" /> : <Eye className="mr-1.5 h-4 w-4" />}
{showPassword ? 'Hide' : 'Show'} password
</Button>
</div>
<Field
label="Require delivery OTP"
inline
hint="The rider must collect a one-time code from the receiver"
className="sm:col-span-2"
>
<Switch checked={form.requiredeliveryotp} onCheckedChange={set('requiredeliveryotp')} />
</Field>
</div>
<div className="mt-6 flex justify-end gap-2 border-t border-border pt-5">
<Button type="button" variant="ghost" onClick={() => navigate('/doormile/tenants')}>
Cancel
</Button>
<Button type="submit" loading={onboard.isPending} disabled={Boolean(unavailable)}>
Onboard client
</Button>
</div>
</Surface>
</form>
<OnboardedList />
</Stack>
);
}

View File

@@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Building2, CheckCircle2, CircleSlash, IndianRupee, Mail, MapPin, Pencil, Phone,
Plus, Timer, Users,
Plus, Timer, UserPlus, Users,
} from 'lucide-react';
import {
Button, DataTable, Drawer, EmptyState, Field, Grid, IconButton, Input, KpiCard,
@@ -14,6 +14,8 @@ import {
useTenants, useUpdateTenant, useUpdateTenantLocation,
} from '@/lib/doormileHooks';
import { currency, matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
import { useAuth } from '@/lib/AuthContext';
import { canOnboardClients } from '@/lib/clientOnboarding';
/**
* Clients — the tenant companies whose deliveries this network carries.
@@ -281,6 +283,8 @@ function PricingDrawer({ tenant, open, onClose }) {
export default function Tenants() {
const navigate = useNavigate();
const { user, isClient } = useAuth();
const canOnboard = canOnboardClients(user, isClient);
const { data: tenants = [], isLoading, isFetching } = useTenants();
const updateTenant = useUpdateTenant();
@@ -456,6 +460,11 @@ export default function Tenants() {
placeholder="Search by client name, email or phone"
className="w-full sm:w-72"
/>
{canOnboard && (
<Button variant="outline" onClick={() => navigate('/doormile/clients/onboard')}>
<UserPlus className="mr-1.5 h-4 w-4" /> Onboard client
</Button>
)}
<Button onClick={() => navigate('/doormile/clients/create')}>
<Plus className="mr-1.5 h-4 w-4" /> New client
</Button>

View File

@@ -49,9 +49,16 @@ const STATUS_TABS = [
const KNOWN_STATUSES = STATUS_TABS.map((tab) => tab.value).concat('canceled');
// `short` is what the list shows: the trigger's clock icon already says
// "batch", so "Morning Batch" repeated the word on every row.
const BATCH_OPTIONS = [
{ id: 'all', label: 'All batches', range: 'Across the day' },
...BATCHES.map((batch) => ({ id: batch.id, label: batch.label, range: batchRangeLabel(batch) })),
{ id: 'all', label: 'All batches', short: 'All batches', range: 'All day' },
...BATCHES.map((batch) => ({
id: batch.id,
label: batch.label,
short: batch.label.replace(/\s*batch$/i, ''),
range: batchRangeLabel(batch),
})),
];
const ALL = 'all';
@@ -431,27 +438,34 @@ export default function Deliveries() {
/>
<ZoneSelector className="w-full sm:w-auto shrink-0" />
{/* Batch. Sized like the Clients and Milers selects beside it.
The trigger names the batch only: SelectItem puts an option's
whole content in ItemText, so a bare <SelectValue/> copied the
time-range badge into the trigger too and needed 210px for it.
The list is a compact label + time, no badges. */}
<Select value={batch} onValueChange={setBatch}>
<SelectTrigger className="w-full sm:w-auto min-w-[210px] h-10">
<SelectTrigger className="w-full sm:w-44" aria-label="Batch">
<div className="flex items-center gap-2 min-w-0">
<Clock className="h-3.5 w-3.5 text-ink-3 shrink-0" />
<SelectValue />
<SelectValue>
<span className="truncate">{BATCH_OPTIONS.find((o) => o.id === batch)?.label || 'All batches'}</span>
</SelectValue>
</div>
</SelectTrigger>
<SelectContent className="w-[300px] p-1.5 shadow-lg rounded-xl">
<SelectContent className="min-w-[var(--radix-select-trigger-width)] w-52">
{BATCH_OPTIONS.map((option) => (
<SelectItem
key={option.id}
value={option.id}
textValue={option.id === 'all' ? 'All batches' : `${option.label} · ${option.range}`}
className="py-2 pl-2.5 pr-8 rounded-lg cursor-pointer my-0.5"
textValue={option.label}
// ItemText is SelectItem's last child and sizes to its text;
// stretched, the times line up in one right-hand column.
className="cursor-pointer [&>span:last-child]:w-full"
>
<div className="flex items-center justify-between gap-3 w-full">
<span className="font-medium text-ink-1 text-sm">{option.label}</span>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-md bg-surface-sunken text-ink-3 border border-border/50 shrink-0">
{option.range}
</span>
</div>
<span className="flex w-full items-center justify-between gap-3">
<span className="text-ink-1">{option.short}</span>
<span className="text-xs text-ink-4 tabular-nums">{option.range}</span>
</span>
</SelectItem>
))}
</SelectContent>

View File

@@ -6,6 +6,7 @@ import {
Textarea,
} from '@/components/ds';
import { ListToolbar } from '@/components/doormile/ListToolbar';
import AgentOperationsBanner from '@/components/doormile/AgentOperationsBanner';
import { useCreateException, useExceptions, useHubs, useUpdateExceptionStatus } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
@@ -209,6 +210,9 @@ export default function Exceptions() {
return (
<Stack space="lg">
{/* Early warnings from the console's monitoring skills (agent registry). */}
<AgentOperationsBanner />
<Grid cols={4}>
<KpiCard
icon={ClipboardList}

View File

@@ -41,21 +41,14 @@ import {
getAdminTenants,
getTenantLocations
} from '@/api/doormile/endpoints';
// calculateDrivingDistance was used by the deadhead-leg effect below without
// ever being imported — a ReferenceError on every customer-pickup order that
// had a pinned collection address, thrown synchronously inside the effect so
// the `.catch()` on that promise chain could never see it. Neither the build
// nor the lint config catches an unbound identifier in a .jsx file, which is
// why it survived. Found while auditing the coordinate changes; unrelated to
// them.
//
// getLastRouteDurationMin is gone from this list: it was imported and never
// called, which the project's own lint reports as an error. Dropped while
// rewriting the statement rather than left behind in it.
// The deadhead-leg effect uses calculateDrivingRoute, not
// calculateDrivingDistance: the latter returns whole km only, so a collection
// point 300 m from the hub read "0 km". Both legs now display `meters`.
import {
calculateDrivingDistance,
calculateDrivingRoute,
calculateTotalCharge
calculateTotalCharge,
formatRouteDistance,
formatRouteDuration
} from '@/lib/distance';
import { buildCityScope, geocodeAddress, saveRecentAddress } from '@/lib/geocodingService';
import { useHubs } from '@/lib/doormileHooks';
@@ -549,9 +542,14 @@ export default function CreateOrder() {
calculateDrivingRoute({ latitude: pLat, longitude: pLng }, { latitude: dLat, longitude: dLng })
.then((res) => {
if (!active) return;
// No floor and no default. `Math.max(1, d || 1)` turned every leg under
// 500 m (OSRM rounds to 0 km) into "1 km", and `minutes || 15` turned a
// sub-minute drive into "15 mins" — a same-address order showed both.
// `distance` (whole km) is still what pricing bills on; `meters` is shown.
setRoute({
distance: Math.max(1, res.distance || 1),
minutes: res.minutes || 15,
distance: Math.max(0, res.distance || 0),
meters: Number.isFinite(res.meters) ? res.meters : null,
minutes: Number.isFinite(res.minutes) ? res.minutes : null,
polyline: res.polyline || [[pLat, pLng], [dLat, dLng]],
resolved: true
});
@@ -591,9 +589,15 @@ export default function CreateOrder() {
}
let active = true;
calculateDrivingDistance({ latitude: oLat, longitude: oLng }, { latitude: pLat, longitude: pLng })
.then((distance) => {
if (active) setDeadhead({ distance: Math.max(0, distance || 0), resolved: true });
calculateDrivingRoute({ latitude: oLat, longitude: oLng }, { latitude: pLat, longitude: pLng })
.then((res) => {
if (active) {
setDeadhead({
distance: Math.max(0, res.distance || 0),
meters: Number.isFinite(res.meters) ? res.meters : null,
resolved: true
});
}
})
.catch(() => {
if (active) setDeadhead({ distance: 0, resolved: false });
@@ -1704,14 +1708,14 @@ export default function CreateOrder() {
<div className="p-3 bg-slate-50 border border-slate-100 rounded-lg text-center">
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Driving Dist.</div>
<div className="text-base font-black text-slate-900 mt-0.5">
{route.resolved ? `${route.distance} km` : '—'}
{route.resolved ? formatRouteDistance(route.meters ?? route.distance * 1000) : '—'}
</div>
</div>
<div className="p-3 bg-slate-50 border border-slate-100 rounded-lg text-center">
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Est. Duration</div>
<div className="text-base font-black text-slate-900 mt-0.5">
{route.minutes ? `${route.minutes} mins` : '—'}
{route.resolved ? formatRouteDuration(route.minutes) : '—'}
</div>
</div>
</div>
@@ -1757,7 +1761,7 @@ export default function CreateOrder() {
<span className="w-2 h-2 rounded-full bg-emerald-500" />
Pickup ➔ <span className="w-2 h-2 rounded-full bg-orange-500" /> Delivery
</span>
<span className="font-semibold text-slate-700">{route.distance} km road path</span>
<span className="font-semibold text-slate-700">{formatRouteDistance(route.meters ?? route.distance * 1000)} road path</span>
</div>
</div>
)}
@@ -1774,7 +1778,7 @@ export default function CreateOrder() {
Hub → collection point · not billed
</div>
</div>
<div className="text-sm font-black text-violet-900 shrink-0 ml-2">{deadhead.distance} km</div>
<div className="text-sm font-black text-violet-900 shrink-0 ml-2">{formatRouteDistance(deadhead.meters ?? deadhead.distance * 1000)}</div>
</div>
)}

View File

@@ -9,19 +9,12 @@ import Customers from '@/pages/doormile/customers/Customers';
import Profitability from '@/pages/doormile/reports/Profitability';
import AgentStudio from './agentStudio/AgentStudio';
// ⚠️ Skills & Tools (Agent Studio) is a PREVIEW. It does not configure
// anything yet.
//
// It reads and writes browser localStorage and nothing else: no endpoint, no
// table, and no importer outside its own folder. A skill added here exists on
// one machine, in one browser, and is invisible to colleagues and to the
// assistant. The Home assistant runs on the regex catalogue in
// lib/assistant/intents.js and has never heard of a "skill".
//
// It is kept deliberately — the shape is right and it is what Phase 6 of
// docs/agent-platform-plan.md wires to the real tool registry in
// doormile_backend/internal/ai/tools. Until then, do not read a change made
// here as a change to the agent's behaviour.
// Skills & Tools (Agent Studio) reads and writes the backend agent registry
// (/admin/ai/*, docs/agent-platform-plan.md Phase 2). Changes are shared and
// audited. The console ops skills (Exceptions banner, chat briefing) read it
// since Phase 3; AI_engine starts reading it in Phase 5. The page says so on
// screen. Until then, a change to an AI_engine agent here does not change
// what that agent does.
//
// It is NOT the default tab. Landing on a section that does nothing is how an
// operator concludes the AI is broken; Profitability leads instead.

View File

@@ -1,225 +1,406 @@
import React, { useState } from 'react';
import {
Sliders,
BarChart3,
ShieldCheck,
TrendingUp,
Cpu,
Save,
} from 'lucide-react';
import { Sliders, BarChart3, ShieldCheck, ShieldAlert, Cpu, Save, Info, Radio } from 'lucide-react';
import { useAiDecisions, useAiInsights } from '@/lib/doormileHooks';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { cn } from '@/lib/utils';
import { OpenToast } from '@/components/third-party/OpenToast';
import { messageOf } from '@/api/doormile/notify';
import { MODEL_OPTIONS } from './registryAdapters';
export function AgentConfigView({ activeAgent }) {
const [model, setModel] = useState('claude-3-5-sonnet');
const [temperature, setTemperature] = useState(0.2);
const [systemPrompt, setSystemPrompt] = useState(
`You are ${activeAgent?.name || 'an autonomous agent'} operating in the Doormile Express logistics ecosystem. Prioritize safety, deterministic tool calling, and high-accuracy dispatch operations.`
function Fact({ label, children }) {
return (
<div className="space-y-0.5">
<dt className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">{label}</dt>
<dd className="text-xs text-slate-800 break-words">{children || '—'}</dd>
</div>
);
const [requireConfirmOnWrite, setRequireConfirmOnWrite] = useState(true);
}
const handleSave = () => {
OpenToast('Agent configuration saved successfully', 'success', 2500);
/**
* The agent's registry record, and the two settings an admin may change:
* the model (AI_engine agents only) and autonomy (only the three agents whose
* writes AI_engine gates). It replaced a form with a temperature slider and a
* model list (GPT-4o, DeepSeek) that nothing read, and a Save that saved nothing.
*/
export function AgentConfigView({ activeAgent, canEdit = false, onSave }) {
const [model, setModel] = useState(activeAgent?.model || '');
const [autonomous, setAutonomous] = useState(Boolean(activeAgent?.autonomous));
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
if (!activeAgent) return null;
const isEngine = activeAgent.runtime === 'engine';
const modelChanged = isEngine && model !== (activeAgent.model || '');
const autonomyChanged = activeAgent.hasAutonomyGate && autonomous !== activeAgent.autonomous;
const turningOn = autonomyChanged && autonomous;
const confirmOk = !turningOn || confirm === activeAgent.id;
const dirty = modelChanged || autonomyChanged;
// A model id the registry holds but this list does not name stays selectable.
const options = MODEL_OPTIONS.some((o) => o.value === model) ? MODEL_OPTIONS : [...MODEL_OPTIONS, { value: model, label: model }];
const handleSave = async () => {
setError('');
const patch = {};
if (modelChanged) patch.model = model;
if (autonomyChanged) patch.autonomous = autonomous;
if (turningOn) patch.confirm = confirm;
setSaving(true);
try {
await onSave(activeAgent.id, patch);
setConfirm('');
} catch (err) {
setError(messageOf(err, 'The agent was not updated'));
} finally {
setSaving(false);
}
};
return (
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs p-6 space-y-6">
<div className="flex items-center justify-between pb-4 border-b border-slate-100">
<div className="flex items-start justify-between gap-4 pb-4 border-b border-slate-100">
<div>
<h3 className="text-base font-bold text-slate-900 font-heading flex items-center gap-2">
<Sliders className="w-4 h-4 text-brand" />
<span>Agent Governance & Model Configuration</span>
<span>{activeAgent.name}</span>
</h3>
<p className="text-xs text-slate-500 mt-0.5">
Configure LLM parameters, system identity, and write safety boundaries for {activeAgent?.name}.
</p>
<p className="text-xs text-slate-500 mt-0.5">{activeAgent.description}</p>
</div>
<button
type="button"
onClick={handleSave}
className="px-4 py-2 rounded-xl bg-brand hover:bg-brand-dark active:scale-95 text-white font-bold text-xs shadow-xs transition-all flex items-center gap-1.5 cursor-pointer"
>
<Save className="w-3.5 h-3.5" />
<span>Save Configuration</span>
</button>
{canEdit && (isEngine || activeAgent.hasAutonomyGate) && (
<button
type="button"
onClick={handleSave}
disabled={!dirty || !confirmOk || saving}
className="px-4 py-2 rounded-xl bg-brand hover:bg-brand-dark active:scale-95 text-white font-bold text-xs shadow-xs transition-all flex items-center gap-1.5 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100"
>
<Save className="w-3.5 h-3.5" />
<span>{saving ? 'Saving…' : 'Save changes'}</span>
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-xs">
{/* Foundation Model Choice */}
<div className="space-y-2">
<label className="font-bold text-slate-800 flex items-center gap-1.5">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Fact label="Status">
{activeAgent.statusMeta.label} — {activeAgent.statusMeta.hint}
</Fact>
<Fact label="Runs in">{isEngine ? 'AI_engine' : 'The console'}</Fact>
<Fact label="Woken by">{activeAgent.wakeon}</Fact>
<Fact label="Implemented at">
<span className="font-mono text-[11px]">{activeAgent.classref}</span>
</Fact>
{activeAgent.llmdecision && (
<Fact label="Model decision">
<span className="font-mono text-[11px]">{activeAgent.llmdecision}</span>
</Fact>
)}
</dl>
{isEngine && (
<div className="space-y-2 text-xs">
<label htmlFor="agent-model" className="font-bold text-slate-800 flex items-center gap-1.5">
<Cpu className="w-3.5 h-3.5 text-brand" />
<span>Primary Model Engine</span>
<span>Model</span>
</label>
<select
id="agent-model"
value={model}
disabled={!canEdit || saving}
onChange={(e) => setModel(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 focus:outline-none focus:border-brand font-medium"
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 focus:outline-none focus:border-brand font-medium disabled:text-slate-500"
>
<option value="claude-3-5-sonnet">Claude 3.5 Sonnet (Recommended - Precision Tool Calling)</option>
<option value="gpt-4o">GPT-4o (High Speed Reasoning)</option>
<option value="deepseek-v3">DeepSeek V3 (Cost Efficient)</option>
{options.map((o) => (
<option key={o.value || 'default'} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
{/* Temperature Slider */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-bold text-slate-800">Determinism (Temperature)</label>
<span className="font-mono font-bold text-brand">{temperature}</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
className="w-full accent-[#C8102E] cursor-pointer"
/>
<div className="flex justify-between text-[10px] text-slate-400">
<span>0.0 (Strictly Deterministic)</span>
<span>1.0 (Creative)</span>
</div>
</div>
</div>
{/* System Prompt Instruction */}
<div className="space-y-2 text-xs">
<label className="font-bold text-slate-800">
Agent System Instruction & Operational Persona
</label>
<textarea
rows={4}
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-2xl p-4 text-xs font-mono text-slate-800 focus:outline-none focus:border-brand focus:bg-white resize-none"
/>
</div>
{/* Safety Gate Checkbox */}
<div className="p-4 rounded-2xl bg-amber-50/70 border border-amber-200/80 flex items-center justify-between">
<div className="space-y-0.5">
<div className="font-bold text-amber-900 text-xs flex items-center gap-1.5">
<ShieldCheck className="w-4 h-4 text-amber-700" />
<span>Enforce Human-in-the-Loop Confirmation on Write Intents</span>
</div>
<p className="text-[11px] text-amber-800/80">
Requires explicit operator approval before executing order creations or fleet reallocations.
</p>
</div>
<button
type="button"
onClick={() => setRequireConfirmOnWrite((v) => !v)}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out',
requireConfirmOnWrite ? 'bg-amber-600' : 'bg-slate-300'
{!activeAgent.llmdecision && (
<p className="text-[11px] text-slate-500">This agent makes no model call today, so the setting has no effect on it.</p>
)}
>
<span
className={cn(
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out',
requireConfirmOnWrite ? 'translate-x-5' : 'translate-x-0'
)}
/>
</button>
</div>
</div>
)}
{activeAgent.hasAutonomyGate ? (
<div className={cn('p-4 rounded-2xl border space-y-3', autonomous ? 'bg-rose-50/70 border-rose-200' : 'bg-amber-50/70 border-amber-200/80')}>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<div className="font-bold text-slate-900 text-xs flex items-center gap-1.5">
{autonomous ? <ShieldAlert className="w-4 h-4 text-rose-700" /> : <ShieldCheck className="w-4 h-4 text-amber-700" />}
<span>Autonomous</span>
</div>
<p className="text-[11px] text-slate-600">
{autonomous
? 'This agent may act without a human — reassign riders or message customers.'
: 'Off: this agent raises alerts and a human decides.'}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={autonomous}
aria-label="Autonomous"
disabled={!canEdit || saving}
onClick={() => {
setAutonomous((v) => !v);
setError('');
}}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out disabled:cursor-not-allowed disabled:opacity-50',
autonomous ? 'bg-rose-600' : 'bg-slate-300'
)}
>
<span
className={cn(
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out',
autonomous ? 'translate-x-5' : 'translate-x-0'
)}
/>
</button>
</div>
{turningOn && (
<label className="block space-y-1 text-[11px]">
<span className="font-semibold text-rose-900">
Type <span className="font-mono">{activeAgent.id}</span> to confirm
</span>
<input
type="text"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="off"
spellCheck={false}
className="w-full rounded-lg border border-rose-300 bg-white px-2.5 py-1.5 font-mono text-xs focus:outline-none focus:border-rose-500"
/>
</label>
)}
</div>
) : (
<p className="flex items-start gap-2 text-[11px] text-slate-500">
<Info className="w-3.5 h-3.5 mt-px shrink-0" />
<span>This agent has no autonomy switch: it never writes to orders, riders or customers on its own.</span>
</p>
)}
{error && (
<div role="alert" className="p-2.5 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 font-semibold text-[11px]">
{error}
</div>
)}
</div>
);
}
export function AgentInsightsView() {
const WINDOWS = [
{ days: 1, label: '24 h' },
{ days: 7, label: '7 days' },
{ days: 30, label: '30 days' },
];
const pct = (part, whole) => (whole > 0 ? `${Math.round((part / whole) * 100)}%` : '—');
const ms = (v) => (v > 0 ? (v >= 1000 ? `${(v / 1000).toFixed(1)} s` : `${Math.round(v)} ms`) : '—');
const when = (raw) => formatDoormileTimestamp(raw, 'DD MMM, hh:mm A');
function Tile({ label, value, hint, tone = 'slate' }) {
const tones = { slate: 'text-slate-900', rose: 'text-rose-700', emerald: 'text-emerald-700' };
return (
<div className="space-y-6">
{/* Metrics Row */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-3xl border border-slate-200/90 shadow-2xs space-y-1">
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Total Invocations
</div>
<div className="text-2xl font-black text-slate-900 font-heading">1,482</div>
<div className="text-[10px] text-emerald-600 font-semibold flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
+18.4% this week
</div>
</div>
<div className="bg-white p-4 rounded-2xl border border-slate-200/90 shadow-2xs space-y-1">
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">{label}</div>
<div className={cn('text-2xl font-black font-heading', tones[tone])}>{value}</div>
{hint && <div className="text-[10px] text-slate-500">{hint}</div>}
</div>
);
}
<div className="bg-white p-4 rounded-3xl border border-slate-200/90 shadow-2xs space-y-1">
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Tool Success Rate
</div>
<div className="text-2xl font-black text-emerald-600 font-heading">99.4%</div>
<div className="text-[10px] text-slate-500 font-medium">0 validation rejections</div>
</div>
/**
* What the agents actually did (plan Phase 4): runs and failures per AI_engine
* agent from its task telemetry, decisions from the decision log, and each
* agent's latest heartbeat. Every figure comes from /admin/ai/insights and
* /admin/ai/decisions; nothing is estimated. It replaced a panel of invented
* numbers ("99.4% success", "1,482 invocations").
*/
export function AgentInsightsView({ agents = [], activeAgent }) {
const [days, setDays] = useState(7);
const insightsQ = useAiInsights(days);
const decisionsQ = useAiDecisions({ limit: 5 });
const data = insightsQ.data;
<div className="bg-white p-4 rounded-3xl border border-slate-200/90 shadow-2xs space-y-1">
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Average Latency
</div>
<div className="text-2xl font-black text-brand font-heading">380ms</div>
<div className="text-[10px] text-emerald-600 font-semibold">Sub-second execution</div>
</div>
const engineAgents = agents.filter((a) => a.runtime === 'engine');
const statsById = Object.fromEntries((data?.runs?.peragent || []).map((s) => [s.agentid, s]));
const liveById = Object.fromEntries((data?.live || []).map((s) => [s.agentid, s]));
<div className="bg-white p-4 rounded-3xl border border-slate-200/90 shadow-2xs space-y-1">
<div className="text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Gated Actions Confirmed
</div>
<div className="text-2xl font-black text-amber-600 font-heading">328</div>
<div className="text-[10px] text-slate-500 font-medium">100% human-verified</div>
</div>
</div>
{/* Tool Distribution Breakdown */}
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs p-6 space-y-4 text-xs">
return (
<div className="space-y-5 text-xs">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm font-bold text-slate-900 font-heading flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-brand" />
<span>Capability Invocation Volume by Category</span>
<span>What the agents did</span>
</h3>
<div className="space-y-3 pt-2">
<div>
<div className="flex justify-between font-bold text-slate-700 mb-1">
<span>Authoring & Skill Management (MileTruth AI)</span>
<span>540 calls (36%)</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full bg-brand rounded-full w-[36%]" />
</div>
</div>
<div>
<div className="flex justify-between font-bold text-slate-700 mb-1">
<span>Workforce Analytics</span>
<span>420 calls (28%)</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full bg-slate-700 rounded-full w-[28%]" />
</div>
</div>
<div>
<div className="flex justify-between font-bold text-slate-700 mb-1">
<span>Autonomous Order Scheduling</span>
<span>310 calls (21%)</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full bg-emerald-600 rounded-full w-[21%]" />
</div>
</div>
<div>
<div className="flex justify-between font-bold text-slate-700 mb-1">
<span>Dispatch Rebalance</span>
<span>212 calls (15%)</span>
</div>
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div className="h-full bg-rose-600 rounded-full w-[15%]" />
</div>
</div>
<div role="group" aria-label="Time window" className="flex rounded-lg border border-slate-200 bg-white p-0.5">
{WINDOWS.map((w) => (
<button
key={w.days}
type="button"
aria-pressed={days === w.days}
onClick={() => setDays(w.days)}
className={cn(
'px-3 py-1 rounded-md font-semibold cursor-pointer',
days === w.days ? 'bg-brand text-white' : 'text-slate-600 hover:bg-slate-50'
)}
>
{w.label}
</button>
))}
</div>
</div>
{activeAgent?.runtime === 'console' && (
<p className="flex items-start gap-2 text-[11px] text-slate-500">
<Info className="w-3.5 h-3.5 mt-px shrink-0" />
<span>
{activeAgent.name} runs in the operator&apos;s browser, so its runs are not recorded. The figures below are
AI_engine&apos;s agents.
</span>
</p>
)}
{insightsQ.isLoading ? (
<div className="rounded-2xl border border-slate-200 bg-white p-8 text-center text-slate-500">Loading agent activity…</div>
) : insightsQ.error ? (
<div role="alert" className="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-800">
<div className="font-bold">Could not load agent activity</div>
<div className="mt-0.5">{messageOf(insightsQ.error)}</div>
</div>
) : (
<>
{!data.receiving && (
<div role="note" className="flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2.5 text-amber-900">
<Radio className="w-4 h-4 mt-px shrink-0 text-amber-600" />
<span>
<span className="font-bold">Not receiving agent telemetry.</span> This backend is not connected to the NATS
server AI_engine publishes to, so agent runs are not being recorded. Decisions still come from the decision log.
</span>
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Tile label="Agent runs" value={data.runs.total.toLocaleString('en-IN')} hint={`last ${WINDOWS.find((w) => w.days === days)?.label || `${days} days`}`} />
<Tile
label="Failed"
value={data.runs.failed.toLocaleString('en-IN')}
hint={`${pct(data.runs.failed, data.runs.total)} of runs`}
tone={data.runs.failed > 0 ? 'rose' : 'slate'}
/>
<Tile label="Decisions" value={data.decisions.total.toLocaleString('en-IN')} hint="from the decision log" />
<Tile
label="Agents reporting"
value={`${data.live.length} / ${engineAgents.length || '—'}`}
hint="heartbeat in the last 5 min"
tone={data.live.length > 0 ? 'emerald' : 'slate'}
/>
</div>
<div className="bg-white rounded-2xl border border-slate-200/90 shadow-2xs overflow-x-auto">
<table className="w-full text-left">
<caption className="sr-only">Runs per AI_engine agent</caption>
<thead className="bg-slate-50/80 text-[11px] font-bold text-slate-600 uppercase tracking-wider">
<tr>
<th className="py-2.5 px-3">Agent</th>
<th className="py-2.5 px-3">Now</th>
<th className="py-2.5 px-3 text-right">Runs</th>
<th className="py-2.5 px-3 text-right">Failed</th>
<th className="py-2.5 px-3 text-right">Avg time</th>
<th className="py-2.5 px-3">Last run</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{engineAgents.map((a) => {
const s = statsById[a.id];
const live = liveById[a.id];
return (
<tr key={a.id} className={cn(a.id === activeAgent?.id && 'bg-brand-tint/30')}>
<td className="py-2 px-3 font-semibold text-slate-800">{a.name}</td>
<td className="py-2 px-3">
{live ? (
<span title={`Last heartbeat ${when(live.lastseenat)}`} className="text-emerald-700 font-semibold">
{live.status || 'up'}
</span>
) : (
<span className="text-slate-400">silent</span>
)}
</td>
<td className="py-2 px-3 text-right tabular-nums">{s ? s.runs.toLocaleString('en-IN') : 0}</td>
<td className={cn('py-2 px-3 text-right tabular-nums', s?.failed > 0 && 'text-rose-700 font-semibold')}>
{s ? s.failed.toLocaleString('en-IN') : 0}
</td>
<td className="py-2 px-3 text-right tabular-nums">{ms(s?.avgdurationms)}</td>
<td className="py-2 px-3 text-slate-500">{s?.lastrunat ? when(s.lastrunat) : '—'}</td>
</tr>
);
})}
</tbody>
</table>
{data.runs.total === 0 && (
<p className="px-3 py-2.5 text-[11px] text-slate-500 border-t border-slate-100">
{data.receiving ? 'No agent runs were recorded in this window.' : 'No runs can be recorded until telemetry is received.'}
</p>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-white rounded-2xl border border-slate-200/90 shadow-2xs p-4 space-y-2">
<h4 className="font-bold text-slate-800 uppercase tracking-wider text-[11px]">Decisions by type</h4>
{data.decisions.bytype.length === 0 ? (
<p className="text-slate-500">No decisions were logged in this window.</p>
) : (
<ul className="space-y-2">
{data.decisions.bytype.map((d) => (
<li key={d.decisiontype}>
<div className="flex justify-between font-semibold text-slate-800">
<span className="font-mono">{d.decisiontype}</span>
<span className="tabular-nums">{d.total.toLocaleString('en-IN')}</span>
</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{Object.entries(d.outcomes).map(([outcome, n]) => (
<span key={outcome} className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] text-slate-600">
{outcome} · {n}
</span>
))}
</div>
</li>
))}
</ul>
)}
</div>
<div className="bg-white rounded-2xl border border-slate-200/90 shadow-2xs p-4 space-y-2">
<h4 className="font-bold text-slate-800 uppercase tracking-wider text-[11px]">Latest decisions</h4>
{decisionsQ.isLoading ? (
<p className="text-slate-500">Loading…</p>
) : decisionsQ.error ? (
<p className="text-rose-700">{messageOf(decisionsQ.error)}</p>
) : (decisionsQ.data || []).length === 0 ? (
<p className="text-slate-500">The decision log is empty.</p>
) : (
<ul className="divide-y divide-slate-100">
{decisionsQ.data.map((d) => (
<li key={d.id} className="py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
<span className="font-mono font-semibold text-slate-800">{d.decisiontype}</span>
<span className="text-[10px] text-slate-400">{when(d.createdat)}</span>
</div>
<div className="text-[11px] text-slate-500">
{d.bookingid ? `Booking #${d.bookingid} · ` : ''}
{d.outcome ? `outcome: ${d.outcome}` : 'outcome not recorded yet'}
</div>
{d.reasoning && <p className="text-[11px] text-slate-600 line-clamp-2">{d.reasoning}</p>}
</li>
))}
</ul>
)}
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -4,64 +4,119 @@ import {
Wrench,
Bot,
Send,
CheckCircle2,
Terminal,
RotateCcw,
ShieldCheck,
AlertTriangle,
} from 'lucide-react';
import { useRunAiPlayground } from '../../../../lib/doormileHooks';
/**
* Agent Studio's Test tab. Each prompt is one real run on the backend
* (POST /admin/ai/playground/run): Claude with the chosen skill's tools, where
* read tools execute with personal data redacted and write/notify tools come
* back as proposals that are never executed. The trace shown is the server's,
* step for step — nothing here is simulated.
*/
const OUTCOME = {
executed: { label: 'executed', cls: 'text-emerald-400' },
proposed: { label: 'proposal only — not executed', cls: 'text-amber-300' },
unavailable: { label: 'not available in the playground', cls: 'text-slate-400' },
rejected: { label: 'refused — not in this skill', cls: 'text-red-400' },
error: { label: 'failed', cls: 'text-red-400' },
};
const MAX_PROMPT = 2000;
/** What to tell the operator when a run fails, from the server's code/status. */
export function playgroundErrorMessage(err) {
const status = err?.response?.status;
const data = err?.response?.data || {};
if (data.code === 'PLAYGROUND_NOT_CONFIGURED') {
return 'The Test playground is not switched on for this server yet (no Claude client is configured). Nothing was run.';
}
if (status === 429) return data.message || 'Playground limit reached. Try again in a few minutes.';
if (status === 403) return 'Only an admin can run the Test playground.';
return data.message || 'The run failed. Nothing was changed.';
}
function pretty(raw) {
if (raw == null) return '';
try {
return JSON.stringify(typeof raw === 'string' ? JSON.parse(raw) : raw, null, 1);
} catch {
return String(raw);
}
}
function TraceStep({ step }) {
if (step.kind === 'text') {
return <div className="text-xs text-slate-200 font-sans whitespace-pre-wrap">{step.text}</div>;
}
const o = OUTCOME[step.outcome] || { label: step.outcome, cls: 'text-slate-300' };
return (
<div className="bg-slate-950 p-2.5 rounded-lg border border-slate-800 text-[11px] space-y-1" data-testid="trace-tool">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 text-amber-400 font-bold">
<Wrench className="w-3 h-3" />
<span>{step.tool}()</span>
{step.toolkind && <span className="text-slate-500 font-normal">[{step.toolkind}]</span>}
</span>
<span className={`${o.cls} font-sans`}>
{o.label} · {step.ms} ms
</span>
</div>
<div className="text-slate-400 text-[10px] break-all">Input: {pretty(step.input)}</div>
{step.result && (
<details className="text-slate-400 text-[10px]">
<summary className="cursor-pointer text-slate-500">Result</summary>
<pre className="whitespace-pre-wrap break-all max-h-48 overflow-y-auto">{pretty(step.result)}</pre>
</details>
)}
</div>
);
}
export default function AgentPlayground({
activeAgent,
skills = [],
initialPrompt = '',
initialSkillId = '',
canRun = false,
}) {
const [query, setQuery] = useState(initialPrompt || '');
const [isRunning, setIsRunning] = useState(false);
const [skillId, setSkillId] = useState(initialSkillId || '');
const [messages, setMessages] = useState([]);
const run = useRunAiPlayground();
useEffect(() => {
if (initialPrompt) {
setQuery(initialPrompt);
}
if (initialPrompt) setQuery(initialPrompt);
}, [initialPrompt]);
const runSimulation = (promptToRun) => {
useEffect(() => {
if (initialSkillId && skills.some((s) => s.id === initialSkillId)) setSkillId(initialSkillId);
}, [initialSkillId, skills]);
const submit = (promptToRun) => {
const text = (promptToRun || query).trim();
if (!text) return;
if (!text || run.isPending || !canRun || !activeAgent) return;
const userMsg = { role: 'user', content: text, id: Date.now() };
setMessages((prev) => [...prev, userMsg]);
setIsRunning(true);
const id = Date.now();
setMessages((prev) => [...prev, { role: 'user', content: text, id }]);
setQuery('');
// Simulate Agent Thought, Tool Selection, and Deterministic execution
setTimeout(() => {
// Find matching skill or tool
const matchedSkill = skills.find(
(s) =>
text.toLowerCase().includes((s.title || '').toLowerCase()) ||
(s.prompt && text.toLowerCase().includes(s.prompt.toLowerCase())) ||
(s.category && text.toLowerCase().includes(s.category.toLowerCase()))
) || skills[0];
const agentResponse = {
role: 'assistant',
id: Date.now() + 1,
skill: matchedSkill?.title || 'System Core',
thought: `Matched query intent to skill "${matchedSkill?.title}". Selecting optimal deterministic tool...`,
toolCall: matchedSkill?.tools?.[0]
? {
name: matchedSkill.tools[0],
args: { query: text, timestamp: new Date().toISOString() },
}
: null,
output: `Agent processed capability [${matchedSkill?.title}]. Autonomous verification succeeded with 100% confidence.`,
};
setMessages((prev) => [...prev, agentResponse]);
setIsRunning(false);
}, 900);
run.mutate(
{ agentid: activeAgent.id, skillid: skillId || undefined, prompt: text },
{
onSuccess: (trace) => setMessages((prev) => [...prev, { role: 'assistant', id: id + 1, trace }]),
onError: (err) =>
setMessages((prev) => [...prev, { role: 'error', id: id + 1, content: playgroundErrorMessage(err) }]),
}
);
};
const selectedSkill = skills.find((s) => s.id === skillId);
return (
<div className="bg-white rounded-3xl border border-slate-200/90 shadow-2xs p-6 space-y-5">
{/* Header */}
@@ -71,11 +126,9 @@ export default function AgentPlayground({
<Bot className="w-5 h-5" />
</div>
<div>
<h3 className="text-base font-bold text-slate-900 font-heading">
Agent Test Playground
</h3>
<h3 className="text-base font-bold text-slate-900 font-heading">Agent Test Playground</h3>
<p className="text-xs text-slate-500">
Run test prompts against {activeAgent?.name || 'this agent'} and inspect tool calling execution traces.
Run a prompt through Claude as {activeAgent?.name || 'this agent'} and see every tool call it makes.
</p>
</div>
</div>
@@ -92,102 +145,134 @@ export default function AgentPlayground({
)}
</div>
{/* Suggested Quick Triggers */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 text-xs">
<span className="text-slate-400 font-semibold shrink-0">Try trigger:</span>
{/* What a run can and cannot do */}
<div
role="note"
aria-label="Playground safety"
className="flex items-start gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3.5 py-2.5 text-xs text-emerald-900"
>
<ShieldCheck className="w-4 h-4 mt-0.5 shrink-0 text-emerald-600" />
<p>
Runs are real Claude calls on live data. Read tools execute with names, phones, addresses and notes removed
before Claude sees them. Write and notify tools are <span className="font-bold">never executed</span> — they
come back as proposals. Admin only, 10 runs per 10 minutes.
</p>
</div>
{/* Scope + triggers */}
<div className="flex flex-wrap items-center gap-2 text-xs">
<label htmlFor="playground-skill" className="text-slate-400 font-semibold">
Skill:
</label>
<select
id="playground-skill"
value={skillId}
onChange={(e) => setSkillId(e.target.value)}
className="bg-slate-50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-xs text-slate-800"
>
<option value="">All enabled skills</option>
{skills.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
{s.enabled ? '' : ' (disabled)'}
</option>
))}
</select>
{skills
.filter((s) => s.prompt)
.filter((s) => s.prompt && (!skillId || s.id === skillId))
.map((s) => (
<button
key={s.id}
type="button"
onClick={() => runSimulation(s.prompt)}
className="px-3 py-1 rounded-full bg-brand-tint/70 border border-brand/20 text-brand-dark hover:bg-brand-tint transition-colors font-medium shrink-0 flex items-center gap-1.5 cursor-pointer"
disabled={!canRun || run.isPending}
onClick={() => {
setSkillId(s.id);
submit(s.prompt);
}}
className="px-3 py-1 rounded-full bg-brand-tint/70 border border-brand/20 text-brand-dark hover:bg-brand-tint transition-colors font-medium shrink-0 flex items-center gap-1.5 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<Sparkles className="w-3 h-3 text-brand" />
<span>"{s.prompt}"</span>
<span>&quot;{s.prompt}&quot;</span>
</button>
))}
</div>
{/* Interactive Trace Output Log */}
<div className="min-h-[260px] max-h-[420px] overflow-y-auto rounded-2xl bg-slate-900 text-slate-100 p-4 font-mono text-xs space-y-4">
{messages.length === 0 && !isRunning && (
{/* Trace */}
<div className="min-h-[260px] max-h-[520px] overflow-y-auto rounded-2xl bg-slate-900 text-slate-100 p-4 font-mono text-xs space-y-4">
{messages.length === 0 && !run.isPending && (
<div className="text-slate-500 text-center py-12 flex flex-col items-center gap-2 font-sans">
<Terminal className="w-8 h-8 opacity-40" />
<div className="font-semibold text-sm">Playground session ready</div>
<p className="text-xs text-slate-400 max-w-sm">
Type a prompt or select a sample trigger above to test tool calling and deterministic intent execution.
{canRun
? `Type a prompt${selectedSkill ? ` for “${selectedSkill.title}”` : ''} to see which tools Claude calls and what they return.`
: 'Only an admin can run the Test playground.'}
</p>
</div>
)}
{messages.map((m) => (
<div key={m.id} className="space-y-2">
{m.role === 'user' ? (
<div className="flex items-start gap-2">
{messages.map((m) => {
if (m.role === 'user') {
return (
<div key={m.id} className="flex items-start gap-2">
<span className="text-red-400 font-bold shrink-0">&gt; USER:</span>
<span className="text-white font-sans text-xs">{m.content}</span>
</div>
) : (
<div className="space-y-2 bg-slate-800/70 p-3.5 rounded-xl border border-slate-700/80">
<div className="flex items-center justify-between text-[11px] text-red-300 font-sans font-bold">
<span>AGENT INTENT RESOLUTION: {m.skill}</span>
<span className="text-emerald-400 flex items-center gap-1">
<CheckCircle2 className="w-3.5 h-3.5" />
Confidence: 99.4%
</span>
</div>
<div className="text-[11px] text-slate-400 font-sans">
<span className="text-slate-500">Reasoning: </span>
{m.thought}
</div>
{m.toolCall && (
<div className="bg-slate-950 p-2.5 rounded-lg border border-slate-800 text-[11px] space-y-1">
<div className="flex items-center gap-1.5 text-amber-400 font-bold">
<Wrench className="w-3 h-3" />
<span>Tool Execution: {m.toolCall.name}()</span>
</div>
<div className="text-slate-400 text-[10px]">
Args: {JSON.stringify(m.toolCall.args)}
</div>
</div>
)}
<div className="text-xs text-emerald-300 font-sans pt-1">
{m.output}
</div>
);
}
if (m.role === 'error') {
return (
<div key={m.id} role="alert" className="flex items-start gap-2 text-amber-300 font-sans text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{m.content}</span>
</div>
)}
</div>
))}
);
}
const t = m.trace || {};
return (
<div key={m.id} className="space-y-2 bg-slate-800/70 p-3.5 rounded-xl border border-slate-700/80">
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-slate-400 font-sans">
<span className="text-red-300 font-bold">{t.model}</span>
<span data-testid="trace-meta">
{t.turns} turn{t.turns === 1 ? '' : 's'} · {t.inputtokens} in / {t.outputtokens} out tokens ·{' '}
{(t.ms / 1000).toFixed(1)} s{t.stopreason === 'max_turns' ? ' · stopped at the turn limit' : ''}
</span>
</div>
{(t.steps || []).map((s, i) => (
<TraceStep key={i} step={s} />
))}
{!(t.steps || []).length && <div className="text-slate-500 font-sans">No output.</div>}
</div>
);
})}
{isRunning && (
{run.isPending && (
<div className="flex items-center gap-2 text-red-400 animate-pulse font-sans text-xs py-2">
<div className="w-3 h-3 border-2 border-red-400 border-t-transparent rounded-full animate-spin" />
<span>Agent evaluating prompt against capability registry...</span>
<span>Running on Claude…</span>
</div>
)}
</div>
{/* Prompt Input Box */}
{/* Prompt */}
<div className="relative flex items-center gap-2">
<input
type="text"
value={query}
maxLength={MAX_PROMPT}
disabled={!canRun}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') runSimulation();
if (e.key === 'Enter') submit();
}}
placeholder="Ask or command the agent to test capability invocation..."
className="flex-1 bg-slate-50 border border-slate-200 rounded-2xl px-4 py-3 text-xs sm:text-sm text-slate-900 focus:outline-none focus:border-brand focus:bg-white shadow-2xs transition-all"
placeholder={canRun ? 'Ask the agent something, e.g. “Which bookings are still unassigned?”' : 'Admin only'}
aria-label="Playground prompt"
className="flex-1 bg-slate-50 border border-slate-200 rounded-2xl px-4 py-3 text-xs sm:text-sm text-slate-900 focus:outline-none focus:border-brand focus:bg-white shadow-2xs transition-all disabled:opacity-60"
/>
<button
type="button"
onClick={() => runSimulation()}
disabled={!query.trim() || isRunning}
onClick={() => submit()}
disabled={!canRun || !query.trim() || run.isPending}
className="px-5 py-3 rounded-2xl bg-brand hover:bg-brand-dark disabled:opacity-50 text-white font-bold text-xs shadow-xs transition-all flex items-center gap-2 cursor-pointer disabled:cursor-not-allowed"
>
<span>Run</span>

View File

@@ -14,9 +14,32 @@ import {
Check,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { AGENTS } from './agentRegistryData';
const STATUS_TONE = {
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-200',
amber: 'bg-amber-50 text-amber-800 border-amber-200',
slate: 'bg-slate-100 text-slate-600 border-slate-200',
rose: 'bg-rose-50 text-rose-700 border-rose-200',
indigo: 'bg-indigo-50 text-indigo-700 border-indigo-200',
};
// The registry's word for what an agent actually does today. Shown beside
// every agent, because a simulated agent that looks live is the failure the
// registry exists to end.
function StatusBadge({ agent }) {
const meta = agent.statusMeta;
return (
<span
title={meta.hint}
className={cn('shrink-0 rounded-full border px-1.5 py-px text-[9.5px] font-bold', STATUS_TONE[meta.tone] || STATUS_TONE.slate)}
>
{meta.label}
</span>
);
}
export default function AgentScopeNavigator({
agents = [],
activeAgentId,
onSelectAgent,
skills = [],
@@ -32,7 +55,8 @@ export default function AgentScopeNavigator({
const [toolsOpen, setToolsOpen] = useState(true);
const [agentMenuOpen, setAgentMenuOpen] = useState(false);
const currentAgent = AGENTS.find((a) => a.id === activeAgentId) || AGENTS[0];
const currentAgent = agents.find((a) => a.id === activeAgentId) || agents[0];
if (!currentAgent) return null;
const agentSkills = skills.filter((s) => s.scope !== 'board');
const boardSkills = skills.filter((s) => s.scope === 'board');
@@ -53,8 +77,9 @@ export default function AgentScopeNavigator({
<div className="text-xs sm:text-sm font-bold text-slate-900 truncate font-heading group-hover:text-brand transition-colors">
{currentAgent.name}
</div>
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{currentAgent.badge || 'AGENT'}
<div className="flex items-center gap-1.5 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
<span>{currentAgent.badge || 'AGENT'}</span>
<StatusBadge agent={currentAgent} />
</div>
</div>
</div>
@@ -72,7 +97,7 @@ export default function AgentScopeNavigator({
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider px-3 py-1.5">
Switch Agent Scope
</div>
{AGENTS.map((agent) => (
{agents.map((agent) => (
<button
key={agent.id}
type="button"
@@ -87,8 +112,11 @@ export default function AgentScopeNavigator({
: 'text-slate-700 hover:bg-slate-50'
)}
>
<span>{agent.name}</span>
{agent.id === activeAgentId && <Check className="w-3.5 h-3.5" />}
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{agent.name}</span>
<StatusBadge agent={agent} />
</span>
{agent.id === activeAgentId && <Check className="w-3.5 h-3.5 shrink-0" />}
</button>
))}
</div>

View File

@@ -1,21 +1,27 @@
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import {
Sliders,
Zap,
Wrench,
Bot,
BarChart3,
AlertTriangle,
Info,
Loader2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/lib/AuthContext';
import {
AGENTS,
SURFACES,
loadStoredSkills,
saveStoredSkills,
loadStoredTools,
loadActiveAgentId,
saveActiveAgentId,
} from './agentRegistryData';
useAiAgents,
useAiSkills,
useAiTools,
useCreateAiSkill,
useUpdateAiAgent,
useUpdateAiSkill,
} from '@/lib/doormileHooks';
import { messageOf } from '@/api/doormile/notify';
import { SURFACES, loadActiveAgentId, saveActiveAgentId } from './agentRegistryData';
import { buildStudioModel, canEditRegistry } from './registryAdapters';
import AgentScopeNavigator from './AgentScopeNavigator';
import SkillCardsGrid from './SkillCardsGrid';
import SkillDetailsDrawer from './SkillDetailsDrawer';
@@ -23,7 +29,6 @@ import CreateSkillModal from './CreateSkillModal';
import ToolRegistryView from './ToolRegistryView';
import AgentPlayground from './AgentPlayground';
import { AgentConfigView, AgentInsightsView } from './AgentConfigAndInsights';
import { OpenToast } from '@/components/third-party/OpenToast';
const TOP_NAV_TABS = [
{ id: 'configure', label: 'Configure', icon: Sliders },
@@ -33,175 +38,251 @@ const TOP_NAV_TABS = [
{ id: 'insights', label: 'Insights', icon: BarChart3 },
];
/**
* Settings → Skills & Tools.
*
* Reads and writes the backend agent registry (/admin/ai/*). Nothing here is
* stored in the browser except which agent was last selected. Writes are
* admin-only; the server enforces that, and the page disables the controls
* rather than letting a manager click into a 403.
*/
export default function AgentStudio() {
const { user } = useAuth();
const canEdit = canEditRegistry(user);
const agentsQ = useAiAgents();
const skillsQ = useAiSkills();
const toolsQ = useAiTools();
const updateSkill = useUpdateAiSkill();
const createSkill = useCreateAiSkill();
const updateAgent = useUpdateAiAgent();
const [activeTab, setActiveTab] = useState('skills');
const [activeAgentId, setActiveAgentId] = useState(loadActiveAgentId());
const [skills, setSkills] = useState(loadStoredSkills());
const [tools, setTools] = useState(loadStoredTools());
const [selectedDrawerSkill, setSelectedDrawerSkill] = useState(null);
const [selectedAgentId, setSelectedAgentId] = useState(loadActiveAgentId());
const [drawerSkillId, setDrawerSkillId] = useState(null);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [testPrompt, setTestPrompt] = useState('');
const [testSkillId, setTestSkillId] = useState('');
const model = useMemo(
() => buildStudioModel(agentsQ.data || [], skillsQ.data || [], toolsQ.data || []),
[agentsQ.data, skillsQ.data, toolsQ.data]
);
// With no saved choice, open on the first agent that has skills: JARVIS sorts
// first and has none, and landing on an empty grid reads as a broken page.
const activeAgent =
model.agentsById[selectedAgentId] || model.agents.find((a) => a.skillCount > 0) || model.agents[0] || null;
const agentSkills = useMemo(
() => model.skills.filter((s) => s.agentId === activeAgent?.id),
[model.skills, activeAgent?.id]
);
const agentTools = useMemo(() => {
const used = new Set(agentSkills.flatMap((s) => s.tools));
return model.tools.filter((t) => used.has(t.name));
}, [agentSkills, model.tools]);
// Read from the query data every render, so the drawer shows the server's
// state after a save rather than a stale copy taken when it opened.
const drawerSkill = model.skills.find((s) => s.id === drawerSkillId) || null;
// Persist agent choice
const handleSelectAgent = (id) => {
setActiveAgentId(id);
setSelectedAgentId(id);
saveActiveAgentId(id);
const agent = AGENTS.find((a) => a.id === id);
OpenToast(`Switched active scope to ${agent?.name || id}`, 'info', 2000);
};
// Toggle Skill Status
const handleToggleStatus = (skillId) => {
setSkills((prev) => {
const next = prev.map((s) => {
if (s.id === skillId) {
const updated = { ...s, enabled: !s.enabled };
OpenToast(
`Skill "${s.title}" is now ${updated.enabled ? 'Enabled' : 'Disabled'}`,
updated.enabled ? 'success' : 'info',
2000
);
return updated;
}
return s;
});
saveStoredSkills(next);
return next;
});
// Sync drawer skill state if open
setSelectedDrawerSkill((prev) => {
if (prev && prev.id === skillId) {
return { ...prev, enabled: !prev.enabled };
}
return prev;
});
const skill = model.skills.find((s) => s.id === skillId);
if (!skill || !canEdit) return;
updateSkill.mutate({ id: skillId, patch: { enabled: !skill.enabled } });
};
// Save new skill
const handleSaveNewSkill = (newSkill) => {
setSkills((prev) => {
const next = [newSkill, ...prev];
saveStoredSkills(next);
return next;
});
OpenToast(`Skill "${newSkill.title}" registered successfully`, 'success', 2500);
};
const handleSaveThresholds = (skillId, thresholds) =>
updateSkill.mutateAsync({ id: skillId, patch: { thresholds } });
const handleCreateSkill = (payload) => createSkill.mutateAsync(payload);
const handleSaveAgent = (agentId, patch) => updateAgent.mutateAsync({ id: agentId, patch });
// Trigger test for skill
const handleTestSkill = (skill) => {
setTestPrompt(skill.prompt || `Test capability: ${skill.title}`);
setTestSkillId(skill.id);
setTestSkillId(skill.id);
setActiveTab('test');
OpenToast(`Loaded "${skill.title}" into test playground`, 'info', 1800);
};
const currentAgent = AGENTS.find((a) => a.id === activeAgentId) || AGENTS[0];
const loading = agentsQ.isLoading || skillsQ.isLoading || toolsQ.isLoading;
const loadError = agentsQ.error || skillsQ.error || toolsQ.error;
const canCreate = canEdit && activeAgent?.runtime === 'console';
const createDisabledReason = !canEdit
? 'Only an admin can add skills.'
: activeAgent?.runtime !== 'console'
? 'AI_engine only runs the skills written in its code, so custom skills can be added to console agents only.'
: '';
return (
<div className="space-y-5 pb-10">
{/* 1. Top Level Mode Tabs (Matching Reference Screenshot: Configure | Skills 2 | Tools | Test | Insights) */}
<div className="flex items-center gap-8 border-b border-slate-200/80 text-xs sm:text-sm font-semibold">
{TOP_NAV_TABS.map((tab) => {
const isActive = activeTab === tab.id;
const badgeCount =
tab.id === 'skills'
? skills.length
: tab.id === 'tools'
? tools.length
: null;
{/* Said on screen: what this page changes today, and what it does not. */}
<div
role="note"
className="flex items-start gap-2.5 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-900"
>
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0 text-amber-600" />
<p>
<span className="font-bold">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 Claude
calls (reads redacted, writes only proposed) once the server has a Claude client; until then it says so.
</p>
</div>
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
'pb-3.5 -mb-px transition-all cursor-pointer flex items-center gap-2 select-none border-b-2',
isActive
? 'border-brand text-brand font-bold'
: 'border-transparent text-slate-500 hover:text-slate-800 hover:border-slate-300'
)}
>
<span>{tab.label}</span>
{badgeCount !== null && (
<span
{!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">
<Info className="w-4 h-4 shrink-0 text-slate-400" />
<span>You can view the registry. Only an admin can change it.</span>
</div>
)}
{loading ? (
<div className="flex items-center justify-center gap-2 rounded-2xl border border-slate-200 bg-white p-10 text-sm text-slate-500">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Loading the agent registry…</span>
</div>
) : loadError ? (
<div role="alert" className="rounded-2xl border border-rose-200 bg-rose-50 p-6 text-sm text-rose-800">
<div className="font-bold">Could not load the agent registry</div>
<div className="mt-1 text-xs">{messageOf(loadError)}</div>
<button
type="button"
onClick={() => {
agentsQ.refetch();
skillsQ.refetch();
toolsQ.refetch();
}}
className="mt-3 rounded-lg border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-800 hover:bg-rose-100 cursor-pointer"
>
Try again
</button>
</div>
) : !activeAgent ? (
<div className="rounded-2xl border border-slate-200 bg-white p-10 text-center text-sm text-slate-500">
The registry has no agents yet. It is seeded when the backend starts.
</div>
) : (
<>
{/* 1. Top Level Mode Tabs */}
<div className="flex items-center gap-8 border-b border-slate-200/80 text-xs sm:text-sm font-semibold">
{TOP_NAV_TABS.map((tab) => {
const isActive = activeTab === tab.id;
const badgeCount =
tab.id === 'skills' ? agentSkills.length : tab.id === 'tools' ? model.tools.length : null;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
'px-2 py-0.5 rounded-full text-[11px] font-bold transition-colors',
'pb-3.5 -mb-px transition-all cursor-pointer flex items-center gap-2 select-none border-b-2',
isActive
? 'bg-brand text-white'
: 'bg-slate-100 text-slate-500'
? 'border-brand text-brand font-bold'
: 'border-transparent text-slate-500 hover:text-slate-800 hover:border-slate-300'
)}
>
{badgeCount}
</span>
<span>{tab.label}</span>
{badgeCount !== null && (
<span
className={cn(
'px-2 py-0.5 rounded-full text-[11px] font-bold transition-colors',
isActive ? 'bg-brand text-white' : 'bg-slate-100 text-slate-500'
)}
>
{badgeCount}
</span>
)}
</button>
);
})}
</div>
{/* 2. Main Studio 2-Column Responsive Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
<div className="lg:col-span-4 xl:col-span-3">
<AgentScopeNavigator
agents={model.agents}
activeAgentId={activeAgent.id}
onSelectAgent={handleSelectAgent}
skills={agentSkills}
tools={agentTools}
surfaces={SURFACES}
onSelectSkill={(skill) => setDrawerSkillId(skill.id)}
onSelectTool={() => setActiveTab('tools')}
/>
</div>
<div className="lg:col-span-8 xl:col-span-9">
{activeTab === 'skills' && (
<SkillCardsGrid
skills={agentSkills}
activeAgent={activeAgent}
onTestSkill={handleTestSkill}
onOpenDetails={(skill) => setDrawerSkillId(skill.id)}
onNewSkill={() => setCreateModalOpen(true)}
canCreate={canCreate}
createDisabledReason={createDisabledReason}
/>
)}
</button>
);
})}
</div>
{/* 2. Main Studio 2-Column Responsive Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
{/* Left Column: Scope & Agent Hierarchy Navigator */}
<div className="lg:col-span-4 xl:col-span-3">
<AgentScopeNavigator
activeAgentId={activeAgentId}
onSelectAgent={handleSelectAgent}
skills={skills}
tools={tools}
surfaces={SURFACES}
onSelectSkill={(skill) => setSelectedDrawerSkill(skill)}
onSelectTool={() => setActiveTab('tools')}
/>
</div>
{activeTab === 'tools' && <ToolRegistryView tools={model.tools} />}
{/* Right Column: Main Content Area */}
<div className="lg:col-span-8 xl:col-span-9">
{activeTab === 'skills' && (
<SkillCardsGrid
skills={skills}
activeAgent={currentAgent}
onTestSkill={handleTestSkill}
onOpenDetails={(skill) => setSelectedDrawerSkill(skill)}
onNewSkill={() => setCreateModalOpen(true)}
/>
)}
{activeTab === 'configure' && (
<AgentConfigView
key={activeAgent.id}
activeAgent={activeAgent}
canEdit={canEdit}
onSave={handleSaveAgent}
/>
)}
{activeTab === 'tools' && <ToolRegistryView tools={tools} />}
{activeTab === 'test' && (
<AgentPlayground
key={activeAgent.id}
activeAgent={activeAgent}
skills={agentSkills}
initialPrompt={testPrompt}
initialSkillId={testSkillId}
canRun={canEdit}
/>
)}
{activeTab === 'configure' && (
<AgentConfigView activeAgent={currentAgent} />
)}
{activeTab === 'insights' && <AgentInsightsView agents={model.agents} activeAgent={activeAgent} />}
</div>
</div>
</>
)}
{activeTab === 'test' && (
<AgentPlayground
activeAgent={currentAgent}
skills={skills}
initialPrompt={testPrompt}
/>
)}
{activeTab === 'insights' && <AgentInsightsView />}
</div>
</div>
{/* Slide-over Skill Details Drawer */}
<SkillDetailsDrawer
skill={selectedDrawerSkill}
tools={tools}
isOpen={Boolean(selectedDrawerSkill)}
onClose={() => setSelectedDrawerSkill(null)}
skill={drawerSkill}
tools={model.tools}
isOpen={Boolean(drawerSkill)}
onClose={() => setDrawerSkillId(null)}
onToggleStatus={handleToggleStatus}
onSaveThresholds={handleSaveThresholds}
onTestSkill={handleTestSkill}
canEdit={canEdit}
isSaving={updateSkill.isPending}
/>
{/* New Skill Creation Modal */}
<CreateSkillModal
isOpen={createModalOpen}
isOpen={createModalOpen && canCreate}
onClose={() => setCreateModalOpen(false)}
onSaveSkill={handleSaveNewSkill}
availableTools={tools}
activeAgentId={activeAgentId}
onSaveSkill={handleCreateSkill}
availableTools={model.tools}
activeAgent={activeAgent}
isSaving={createSkill.isPending}
/>
</div>
);

View File

@@ -1,223 +1,222 @@
import React, { useState } from 'react';
import { X, Plus, Sparkles, Wrench, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { messageOf } from '@/api/doormile/notify';
const CATEGORIES = ['Authoring', 'Workforce', 'Logistics', 'Dispatch', 'Custom'];
// Registry categories in use. Stored lowercase; the cards upper-case them.
const CATEGORIES = [
{ value: 'logistics', label: 'Logistics' },
{ value: 'dispatch', label: 'Dispatch' },
{ value: 'operations', label: 'Operations' },
{ value: 'sla_management', label: 'SLA management' },
{ value: 'custom', label: 'Custom' },
];
const EMPTY = { title: '', category: 'logistics', description: '', prompt: '', tools: [] };
/**
* Registers a skill in the backend registry (POST /admin/ai/skills). A skill
* may only use tools that already exist, and only on a console agent — the
* server enforces both. The modal stays open on a refusal and shows why.
*/
export default function CreateSkillModal({
isOpen,
onClose,
onSaveSkill,
availableTools = [],
activeAgentId,
activeAgent,
isSaving = false,
}) {
const [title, setTitle] = useState('');
const [category, setCategory] = useState('Workforce');
const [description, setDescription] = useState('');
const [prompt, setPrompt] = useState('');
const [integration, setIntegration] = useState('MileTruth AI');
const [selectedTools, setSelectedTools] = useState([]);
const [form, setForm] = useState(EMPTY);
const [error, setError] = useState('');
if (!isOpen) return null;
const toggleTool = (toolName) => {
setSelectedTools((prev) =>
prev.includes(toolName)
? prev.filter((t) => t !== toolName)
: [...prev, toolName]
);
const set = (field) => (e) => {
setForm((f) => ({ ...f, [field]: e.target.value }));
setError('');
};
const handleSubmit = (e) => {
e.preventDefault();
if (!title.trim()) {
setError('Please provide a skill title');
return;
}
if (!description.trim()) {
setError('Please provide a skill description');
return;
}
const toggleTool = (toolName) => {
setForm((f) => ({
...f,
tools: f.tools.includes(toolName) ? f.tools.filter((t) => t !== toolName) : [...f.tools, toolName],
}));
setError('');
};
const newSkill = {
id: `skill-${Date.now()}`,
title: title.trim(),
category: category.toUpperCase(),
scope: 'agent',
integration: integration.trim() || 'Custom',
enabled: true,
statusBadge: 'Added',
description: description.trim(),
prompt: prompt.trim() || null,
tools: selectedTools,
agentId: activeAgentId || 'miletruth-agent',
createdAt: new Date().toISOString().split('T')[0],
};
onSaveSkill(newSkill);
const close = () => {
setForm(EMPTY);
setError('');
onClose();
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.title.trim()) return setError('Please provide a skill title');
if (!form.description.trim()) return setError('Please provide a skill description');
if (form.tools.length === 0) return setError('Attach at least one tool');
try {
await onSaveSkill({
agentid: activeAgent?.id,
title: form.title.trim(),
category: form.category,
description: form.description.trim(),
sampleprompt: form.prompt.trim(),
tools: form.tools,
});
close();
} catch (err) {
setError(messageOf(err, 'The skill was not registered'));
}
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="fixed inset-0 bg-slate-900/40 backdrop-blur-xs transition-opacity animate-in fade-in"
onClick={onClose}
/>
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-xs transition-opacity animate-in fade-in" onClick={close} />
{/* Modal Dialog */}
<div className="relative bg-white rounded-3xl max-w-lg w-full shadow-2xl border border-slate-200 p-6 z-50 animate-in zoom-in-95 duration-150">
{/* Header */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="create-skill-title"
className="relative bg-white rounded-3xl max-w-lg w-full shadow-2xl border border-slate-200 p-6 z-50 animate-in zoom-in-95 duration-150"
>
<div className="flex items-center justify-between pb-4 border-b border-slate-100">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-brand-tint text-brand flex items-center justify-center">
<Plus className="w-4 h-4 stroke-[2.5]" />
</div>
<div>
<h3 className="text-base font-bold text-slate-900 font-heading">
<h3 id="create-skill-title" className="text-base font-bold text-slate-900 font-heading">
Register New Agent Skill
</h3>
<p className="text-[11px] text-slate-500">
Define capabilities and connect tools for this agent scope.
</p>
<p className="text-[11px] text-slate-500">For {activeAgent?.name}. Built only from existing tools.</p>
</div>
</div>
<button
type="button"
onClick={onClose}
onClick={close}
aria-label="Close"
className="p-1 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4 pt-4 text-xs">
{error && (
<div className="p-2.5 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 font-semibold text-[11px]">
<div role="alert" className="p-2.5 rounded-xl bg-rose-50 border border-rose-200 text-rose-700 font-semibold text-[11px]">
{error}
</div>
)}
{/* Skill Title */}
<div className="space-y-1">
<label className="font-bold text-slate-700">Skill Title *</label>
<label htmlFor="skill-title" className="font-bold text-slate-700">
Skill Title *
</label>
<input
id="skill-title"
type="text"
value={title}
onChange={(e) => {
setTitle(e.target.value);
setError('');
}}
placeholder="e.g. Dynamic Fleet Rebalance"
value={form.title}
maxLength={120}
onChange={set('title')}
placeholder="e.g. Night shift watch"
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white"
/>
</div>
{/* Category & Scope Integration */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="font-bold text-slate-700">Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="font-bold text-slate-700">Integration Tag</label>
<input
type="text"
value={integration}
onChange={(e) => setIntegration(e.target.value)}
placeholder="e.g. MileTruth AI, Doormile API"
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white"
/>
</div>
<div className="space-y-1">
<label htmlFor="skill-category" className="font-bold text-slate-700">
Category
</label>
<select
id="skill-category"
value={form.category}
onChange={set('category')}
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white"
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
{/* Description */}
<div className="space-y-1">
<label className="font-bold text-slate-700">Description *</label>
<label htmlFor="skill-description" className="font-bold text-slate-700">
Description *
</label>
<textarea
id="skill-description"
rows={2}
value={description}
onChange={(e) => {
setDescription(e.target.value);
setError('');
}}
placeholder="Detailed description of what this capability accomplishes for the agent..."
value={form.description}
onChange={set('description')}
placeholder="What this skill does for the agent…"
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white resize-none"
/>
</div>
{/* Sample Trigger Prompt */}
<div className="space-y-1">
<label className="font-bold text-slate-700 flex items-center gap-1.5">
<label htmlFor="skill-prompt" className="font-bold text-slate-700 flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-brand" />
<span>Sample Trigger Prompt</span>
</label>
<input
id="skill-prompt"
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder='e.g. "How is workforce training progressing?"'
value={form.prompt}
onChange={set('prompt')}
placeholder='e.g. "Which orders are about to breach SLA?"'
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2 text-xs text-slate-900 focus:outline-none focus:border-brand focus:bg-white"
/>
</div>
{/* Connect Tools Checklist */}
<div className="space-y-1.5">
<label className="font-bold text-slate-700 flex items-center gap-1.5">
<div className="font-bold text-slate-700 flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-slate-500" />
<span>Attach Tool Registry Endpoints</span>
</label>
<div className="max-h-32 overflow-y-auto border border-slate-200 rounded-xl p-2 bg-slate-50/50 space-y-1">
<span>Attach Tools *</span>
</div>
<div className="max-h-40 overflow-y-auto border border-slate-200 rounded-xl p-2 bg-slate-50/50 space-y-1">
{availableTools.map((tool) => {
const isSelected = selectedTools.includes(tool.name);
const isSelected = form.tools.includes(tool.name);
return (
<button
key={tool.name}
type="button"
aria-pressed={isSelected}
onClick={() => toggleTool(tool.name)}
className={cn(
'w-full flex items-center justify-between p-2 rounded-lg text-left text-[11px] transition-colors cursor-pointer',
isSelected
? 'bg-brand-tint text-brand font-semibold border border-brand/30'
: 'hover:bg-slate-100 text-slate-700'
'w-full flex items-center justify-between gap-2 p-2 rounded-lg text-left text-[11px] transition-colors cursor-pointer',
isSelected ? 'bg-brand-tint text-brand font-semibold border border-brand/30' : 'hover:bg-slate-100 text-slate-700'
)}
>
<span className="font-mono">{tool.name}</span>
{isSelected && <Check className="w-3.5 h-3.5 text-brand" />}
<span className="font-mono truncate">{tool.name}</span>
<span className="flex items-center gap-1.5 shrink-0">
<span className="text-[9.5px] font-sans text-slate-400">{tool.category}</span>
{isSelected && <Check className="w-3.5 h-3.5 text-brand" />}
</span>
</button>
);
})}
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-100">
<button
type="button"
onClick={onClose}
onClick={close}
className="px-4 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 font-semibold text-xs cursor-pointer"
>
Cancel
</button>
<button
type="submit"
className="px-5 py-2 rounded-xl bg-brand hover:bg-brand-dark active:scale-95 text-white font-bold text-xs shadow-xs cursor-pointer"
disabled={isSaving}
className="px-5 py-2 rounded-xl bg-brand hover:bg-brand-dark active:scale-95 text-white font-bold text-xs shadow-xs cursor-pointer disabled:opacity-50"
>
Add Skill
{isSaving ? 'Saving…' : 'Add Skill'}
</button>
</div>
</form>

View File

@@ -73,7 +73,7 @@ export default function SkillCard({ skill, onTest, onDetails }) {
)}
>
<CategoryIcon className="w-3 h-3" />
<span>{(skill.category || 'SKILL').toUpperCase()}</span>
<span>{(skill.category || 'SKILL').toUpperCase().replace(/_/g, ' ')}</span>
</div>
<div

View File

@@ -9,6 +9,8 @@ export default function SkillCardsGrid({
onTestSkill,
onOpenDetails,
onNewSkill,
canCreate = true,
createDisabledReason = '',
}) {
const [scopeTab, setScopeTab] = useState('agent'); // 'agent' or 'board'
const [searchQuery, setSearchQuery] = useState('');
@@ -140,7 +142,7 @@ export default function SkillCardsGrid({
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 no-scrollbar">
{Object.entries(categories).map(([cat, count]) => {
const isSelected = categoryFilter === cat;
const displayLabel = cat === 'ALL' ? 'All' : cat.charAt(0) + cat.slice(1).toLowerCase();
const displayLabel = cat === 'ALL' ? 'All' : (cat.charAt(0) + cat.slice(1).toLowerCase()).replace(/_/g, ' ');
return (
<button
@@ -181,7 +183,9 @@ export default function SkillCardsGrid({
<button
type="button"
onClick={onNewSkill}
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full bg-white border border-brand text-brand hover:bg-brand-tint font-semibold text-xs shadow-2xs transition-all active:scale-95 cursor-pointer"
disabled={!canCreate}
title={canCreate ? undefined : createDisabledReason}
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full bg-white border border-brand text-brand hover:bg-brand-tint font-semibold text-xs shadow-2xs transition-all active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100 disabled:hover:bg-white"
>
<Plus className="w-3.5 h-3.5 stroke-[2.5]" />
<span>New skill</span>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
X,
Sparkles,
@@ -8,8 +8,124 @@ import {
Code,
ChevronDown,
ChevronRight,
SlidersHorizontal,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { messageOf } from '@/api/doormile/notify';
const SOURCE_LABEL = { engine: 'AI_engine', console: 'Console', custom: 'Custom (made here)' };
/**
* Numeric thresholds, edited against the ranges the registry allows. The
* browser checks the range and step so a typo is caught before the round
* trip; the server checks them again and its word is final.
*/
function ThresholdsEditor({ skill, canEdit, isSaving, onSave }) {
const schema = skill.thresholdsSchema || [];
const [draft, setDraft] = useState(() => ({ ...skill.thresholds }));
const [error, setError] = useState('');
// A save (or another admin's change) arrives as a new version: resync.
useEffect(() => {
setDraft({ ...skill.thresholds });
setError('');
}, [skill.id, skill.version]); // eslint-disable-line react-hooks/exhaustive-deps
if (schema.length === 0) return null;
const dirty = schema.some((s) => Number(draft[s.key]) !== Number(skill.thresholds[s.key]));
const problemFor = (spec) => {
const v = Number(draft[spec.key]);
if (draft[spec.key] === '' || !Number.isFinite(v)) return 'Enter a number';
if (v < spec.min || v > spec.max) return `Between ${spec.min} and ${spec.max}`;
const steps = (v - spec.min) / spec.step;
if (spec.step > 0 && Math.abs(steps - Math.round(steps)) > 1e-6) return `In steps of ${spec.step}`;
return '';
};
const invalid = schema.some((s) => problemFor(s));
const save = async () => {
setError('');
const changed = {};
schema.forEach((s) => {
if (Number(draft[s.key]) !== Number(skill.thresholds[s.key])) changed[s.key] = Number(draft[s.key]);
});
try {
await onSave(skill.id, changed);
} catch (err) {
setError(messageOf(err, 'The thresholds were not saved'));
}
};
return (
<div className="space-y-2.5">
<h3 className="font-bold text-slate-800 uppercase tracking-wider text-[11px] flex items-center gap-1.5">
<SlidersHorizontal className="w-3.5 h-3.5 text-slate-500" />
<span>Thresholds</span>
</h3>
<div className="space-y-2">
{schema.map((spec) => {
const problem = problemFor(spec);
return (
<label key={spec.key} className="flex items-center justify-between gap-3 p-2.5 rounded-xl border border-slate-200 bg-white">
<span className="min-w-0">
<span className="block font-semibold text-slate-800">{spec.label}</span>
<span className="block text-[10px] text-slate-400">
{spec.min}–{spec.max}
{spec.unit ? ` ${spec.unit}` : ''} · step {spec.step} · default {spec.default}
</span>
{problem && <span className="block text-[10px] font-semibold text-rose-600">{problem}</span>}
</span>
<input
type="number"
aria-label={spec.label}
value={draft[spec.key] ?? ''}
min={spec.min}
max={spec.max}
step={spec.step}
disabled={!canEdit || isSaving}
onChange={(e) => setDraft((d) => ({ ...d, [spec.key]: e.target.value === '' ? '' : Number(e.target.value) }))}
className={cn(
'w-24 shrink-0 rounded-lg border px-2 py-1 text-right font-mono text-xs focus:outline-none disabled:bg-slate-50 disabled:text-slate-500',
problem ? 'border-rose-300 focus:border-rose-500' : 'border-slate-200 focus:border-brand'
)}
/>
</label>
);
})}
</div>
{error && (
<div role="alert" className="p-2 rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-[11px] font-semibold">
{error}
</div>
)}
{canEdit && (
<div className="flex justify-end gap-2">
<button
type="button"
disabled={!dirty || isSaving}
onClick={() => {
setDraft({ ...skill.thresholds });
setError('');
}}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 font-semibold disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
>
Reset
</button>
<button
type="button"
disabled={!dirty || invalid || isSaving}
onClick={save}
className="px-3 py-1.5 rounded-lg bg-brand text-white font-bold disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed"
>
{isSaving ? 'Saving…' : 'Save thresholds'}
</button>
</div>
)}
</div>
);
}
export default function SkillDetailsDrawer({
skill,
@@ -17,7 +133,10 @@ export default function SkillDetailsDrawer({
isOpen,
onClose,
onToggleStatus,
onSaveThresholds,
onTestSkill,
canEdit = false,
isSaving = false,
}) {
const [schemaOpen, setSchemaOpen] = useState(false);
@@ -63,15 +182,20 @@ export default function SkillDetailsDrawer({
<div>
<div className="font-bold text-slate-800 text-sm">Capability Status</div>
<div className="text-slate-500 text-[11px] mt-0.5">
{isEnabled ? 'Available to this agent on active surfaces' : 'Disabled for this agent'}
{isEnabled ? 'Enabled in the registry' : 'Disabled in the registry'}
{!canEdit && ' · only an admin can change this'}
</div>
</div>
<button
type="button"
role="switch"
aria-checked={isEnabled}
aria-label={`${skill.title} enabled`}
disabled={!canEdit || isSaving}
onClick={() => onToggleStatus(skill.id)}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:cursor-not-allowed disabled:opacity-50',
isEnabled ? 'bg-brand' : 'bg-slate-300'
)}
>
@@ -129,12 +253,19 @@ export default function SkillDetailsDrawer({
<Layers className="w-3.5 h-3.5 text-slate-500" />
<span>{skill.integration || 'System Core'}</span>
</div>
<div className="px-3 py-1.5 rounded-xl bg-slate-100 border border-slate-200 font-semibold text-slate-700 capitalize">
Scope: {skill.scope === 'board' ? 'Board' : 'Agent Core'}
<div className="px-3 py-1.5 rounded-xl bg-slate-100 border border-slate-200 font-semibold text-slate-700">
Defined in: {SOURCE_LABEL[skill.source] || skill.source}
</div>
{skill.version != null && (
<div className="px-3 py-1.5 rounded-xl bg-slate-100 border border-slate-200 font-mono text-slate-600">
v{skill.version}
</div>
)}
</div>
</div>
<ThresholdsEditor skill={skill} canEdit={canEdit} isSaving={isSaving} onSave={onSaveThresholds} />
{/* 5. Connected Tools from Registry */}
<div className="space-y-2.5">
<div className="flex items-center justify-between">

View File

@@ -35,7 +35,8 @@ export default function ToolRegistryView({ tools = [] }) {
<span>Agent Tool Registry (Schema Engine)</span>
</h3>
<p className="text-xs text-slate-500 mt-1">
Central registry providing JSONSchema parameter validation and human-in-the-loop confirmation gates.
Every capability a skill may call, from the Doormile agent registry. Write and messaging tools need an
operator's confirmation unless their agent is autonomous.
</p>
</div>
@@ -72,8 +73,8 @@ export default function ToolRegistryView({ tools = [] }) {
<thead className="bg-slate-50/80 border-b border-slate-200/80 text-[11px] font-bold text-slate-600 uppercase tracking-wider">
<tr>
<th className="py-3 px-4">Tool Function</th>
<th className="py-3 px-4">Category</th>
<th className="py-3 px-4">Parent Skill</th>
<th className="py-3 px-4">Kind</th>
<th className="py-3 px-4">Used by</th>
<th className="py-3 px-4">Safety Gate</th>
<th className="py-3 px-4 text-right">Arguments</th>
</tr>
@@ -120,6 +121,9 @@ export default function ToolRegistryView({ tools = [] }) {
</td>
<td className="py-3 px-4">
{/* It read "Autonomous" for every ungated tool, which
described a read-only lookup as an agent acting on
its own. Ungated tools are reads or internal events. */}
{tool.requiresConfirmation ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-50 text-amber-800 border border-amber-200">
<ShieldAlert className="w-3 h-3" />
@@ -128,7 +132,7 @@ export default function ToolRegistryView({ tools = [] }) {
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-200">
<ShieldCheck className="w-3 h-3" />
Autonomous
No confirmation needed
</span>
)}
</td>
@@ -144,6 +148,19 @@ export default function ToolRegistryView({ tools = [] }) {
{isExpanded && (
<tr className="bg-slate-50/90">
<td colSpan={5} className="p-4 pl-9 space-y-3">
{(tool.target || tool.implementedAt) && (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[11px]">
<dt className="font-semibold text-slate-500">Touches</dt>
<dd className="font-mono text-slate-800 break-all">{tool.target || '—'}</dd>
<dt className="font-semibold text-slate-500">Implemented at</dt>
<dd className="font-mono text-slate-800 break-all">{tool.implementedAt || '—'}</dd>
</dl>
)}
{(!tool.parameters || tool.parameters.length === 0) && (
<p className="text-[11px] text-slate-500">
No parameters are pinned down for this tool yet — its input is open.
</p>
)}
<div className="text-xs font-bold text-slate-800 flex items-center gap-2">
<Code className="w-3.5 h-3.5 text-brand" />
<span>Tool Parameters Schema (OpenAI / Claude JSONSchema Specification)</span>

View File

@@ -1,241 +1,43 @@
// ==============================|| Agent Studio Data & Registry Layer ||============================== //
// Modeled directly after AI_engine/core/tool_registry.py and the reference screenshot.
export const AGENTS = [
{
id: 'miletruth-agent',
name: 'MileTruth AI Agent',
badge: 'PRIMARY AGENT',
iconType: 'sparkles',
description: 'Autonomous logistics intelligence, workforce analytics, order scheduling, and dynamic fleet dispatch.',
activeSurfacesCount: 3,
},
{
id: 'miletruth-dispatch-agent',
name: 'MileTruth AI Dispatcher',
badge: 'AGENT',
iconType: 'bot',
description: 'Real-time courier routing, SLA breach mitigation, and autonomous fleet allocation.',
activeSurfacesCount: 2,
},
];
// ==============================|| Agent Studio — local-only data ||============================== //
//
// Agents, skills and tools come from the backend registry (/admin/ai/*) since
// Phase 2 of docs/agent-platform-plan.md. What is left here is not registry
// data: the console surfaces an agent appears on, and which agent this browser
// last had selected (a per-viewer convenience, not shared state).
export const SURFACES = [
{
id: 'surf-home',
name: 'Home AI Assistant',
path: '/doormile/home',
description: 'Conversational natural language interface for fleet queries and order creation.',
status: 'active',
},
{
id: 'surf-dispatch',
name: 'Live Dispatch Board',
path: '/doormile/dispatch',
description: 'Real-time kanban grid and map board with autonomous allocation controls.',
description: 'Conversational interface for fleet queries and order creation.',
status: 'active',
},
{
id: 'surf-control-x',
name: 'Control X Fast Ops',
name: 'Control X',
path: '/doormile/control-x',
description: 'High-speed keyboard-driven workspace for rapid order actions and exceptions.',
description: 'Dispatch board with map, optimiser and assignment controls.',
status: 'active',
},
];
export const DEFAULT_TOOLS = [
{
name: 'open_add_skill_training',
parentSkill: 'MileTruth Skill Management',
description: 'Launch modal to configure workforce training modules and curriculum tracks.',
category: 'Authoring',
requiresConfirmation: false,
parameters: [
{ name: 'skill_id', type: 'string', description: 'Unique identifier of the target skill', required: true },
{ name: 'curriculum_track', type: 'string', description: 'Assigned curriculum difficulty tier', required: false, default: 'standard' },
],
},
{
name: 'open_add_training',
parentSkill: 'MileTruth Skill Management',
description: 'Add specific interactive training exercises, quizzes, or step-by-step checklists.',
category: 'Authoring',
requiresConfirmation: false,
parameters: [
{ name: 'training_title', type: 'string', description: 'Title of the training unit', required: true },
{ name: 'estimated_minutes', type: 'number', description: 'Duration in minutes', required: false, default: 15 },
],
},
{
name: 'open_miletruth_ai',
parentSkill: 'MileTruth Skill Management',
description: 'Direct deep-link launch into the MileTruth AI authoring and verification studio.',
category: 'Authoring',
requiresConfirmation: false,
parameters: [
{ name: 'workspace_view', type: 'string', description: 'Initial view mode in MileTruth AI', required: false, default: 'editor' },
],
},
{
name: 'query_learning_analytics',
parentSkill: 'Learning Analysis',
description: 'Query completion rates, assessment scores, and training progress across teams.',
category: 'Workforce',
requiresConfirmation: false,
parameters: [
{ name: 'team_id', type: 'string', description: 'Specific workforce team or all', required: false, default: 'all' },
{ name: 'timeframe', type: 'string', description: '7d, 30d, or 90d window', required: false, default: '30d' },
],
},
{
name: 'create_single_order',
parentSkill: 'Order Intake & Auto-Schedule',
description: 'Create an individual delivery booking in Doormile with geocoded coordinates.',
category: 'Logistics',
requiresConfirmation: true,
parameters: [
{ name: 'pickup_address', type: 'string', description: 'Full verified pickup address', required: true },
{ name: 'drop_address', type: 'string', description: 'Full verified destination address', required: true },
{ name: 'client_id', type: 'string', description: 'Billing client identifier', required: true },
{ name: 'weight_kg', type: 'number', description: 'Estimated package weight', required: false, default: 1.0 },
],
},
{
name: 'rebalance_riders',
parentSkill: 'Dispatch Rebalance & Allocation',
description: 'Autonomous algorithmic reallocation of idle or nearby riders to high-demand zones.',
category: 'Dispatch',
requiresConfirmation: true,
parameters: [
{ name: 'source_zone', type: 'string', description: 'Zone with surplus rider capacity', required: true },
{ name: 'target_zone', type: 'string', description: 'Zone experiencing demand spike', required: true },
{ name: 'rider_count', type: 'integer', description: 'Number of riders to request reassignment', required: true },
],
},
];
export const DEFAULT_SKILLS = [
{
id: 'miletruth-skill-mgmt',
title: 'MileTruth Skill Management',
category: 'AUTHORING',
categoryTheme: 'red',
scope: 'agent',
integration: 'MileTruth AI',
enabled: true,
statusBadge: 'Added',
description: 'Create workforce skills, training and verification in MileTruth AI.',
prompt: null,
tools: ['open_add_skill_training', 'open_add_training', 'open_miletruth_ai'],
agentId: 'miletruth-agent',
createdAt: '2026-03-10',
},
{
id: 'learning-analysis',
title: 'Learning Analysis',
category: 'WORKFORCE',
categoryTheme: 'slate',
scope: 'agent',
integration: 'MileTruth AI',
enabled: true,
statusBadge: 'Added',
description: 'Report what the training library holds and how far the workforce has progressed through it.',
prompt: 'How is training progressing?',
tools: ['query_learning_analytics'],
agentId: 'miletruth-agent',
createdAt: '2026-03-12',
},
{
id: 'order-intake-schedule',
title: 'Order Intake & Auto-Schedule',
category: 'LOGISTICS',
categoryTheme: 'emerald',
scope: 'agent',
integration: 'Doormile API',
enabled: true,
statusBadge: 'Added',
description: 'Parse multi-item orders from batch text or CSV and schedule with live dispatch.',
prompt: "Repeat yesterday's orders for client and schedule run",
tools: ['create_single_order'],
agentId: 'miletruth-agent',
createdAt: '2026-03-15',
},
{
id: 'dispatch-rebalance-alloc',
title: 'Dispatch Rebalance & Allocation',
category: 'DISPATCH',
categoryTheme: 'rose',
scope: 'agent',
integration: 'Doormile Fleet',
enabled: true,
statusBadge: 'Added',
description: 'Analyze live SLA queues and automatically reassign unassigned orders to active nearby couriers.',
prompt: 'Rebalance available riders into Zone 1 to prevent SLA delays',
tools: ['rebalance_riders'],
agentId: 'miletruth-agent',
createdAt: '2026-03-18',
},
];
const STORAGE_KEY_SKILLS = 'doormile_agent_skills_v2';
const STORAGE_KEY_TOOLS = 'doormile_agent_tools_v2';
const STORAGE_KEY_AGENT = 'doormile_active_agent_v2';
export function loadStoredSkills() {
try {
const raw = localStorage.getItem(STORAGE_KEY_SKILLS);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
}
} catch (e) {
console.warn('Failed to parse stored skills, using defaults:', e);
}
return DEFAULT_SKILLS;
}
export function saveStoredSkills(skills) {
try {
localStorage.setItem(STORAGE_KEY_SKILLS, JSON.stringify(skills));
} catch (e) {
console.error('Failed to save skills to localStorage:', e);
}
}
export function loadStoredTools() {
try {
const raw = localStorage.getItem(STORAGE_KEY_TOOLS);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
}
} catch (e) {
console.warn('Failed to parse stored tools, using defaults:', e);
}
return DEFAULT_TOOLS;
}
export function saveStoredTools(tools) {
try {
localStorage.setItem(STORAGE_KEY_TOOLS, JSON.stringify(tools));
} catch (e) {
console.error('Failed to save tools to localStorage:', e);
}
}
const STORAGE_KEY_AGENT = 'doormile_active_agent_v3';
// Storage can throw (private window, blocked site data); the page must still
// render, so every access is guarded and falls back to "no preference".
export function loadActiveAgentId() {
try {
return localStorage.getItem(STORAGE_KEY_AGENT) || 'miletruth-agent';
return localStorage.getItem(STORAGE_KEY_AGENT) || '';
} catch {
return 'miletruth-agent';
return '';
}
}
export function saveActiveAgentId(id) {
try {
localStorage.setItem(STORAGE_KEY_AGENT, id);
} catch (e) {
console.warn('Failed to store active agent:', e);
} catch {
// A preference that cannot be saved is not an error worth surfacing.
}
}

View File

@@ -0,0 +1,133 @@
// ==============================|| Agent Studio — registry adapters ||============================== //
//
// The backend registry (/admin/ai/*, doormile_backend internal/ai/registry)
// speaks in its own column names. The Agent Studio components were written
// against the old local mock's shape. These pure functions translate one into
// the other, so the components only change where they were showing something
// untrue — and the translation is unit-tested on its own.
/** What each agent status means, for the badge beside its name. */
export const AGENT_STATUS = {
live: { label: 'Live', tone: 'emerald', hint: 'Running in production.' },
partial: { label: 'Partial', tone: 'amber', hint: 'Runs, but parts of it are unfinished or rarely reached.' },
simulation: { label: 'Simulation', tone: 'slate', hint: 'Works on made-up data. Does not touch real orders or riders.' },
broken: { label: 'Broken', tone: 'rose', hint: 'Has known defects that stop it working.' },
unmerged: { label: 'Not merged', tone: 'indigo', hint: 'Built on a branch; not in the running console yet.' },
retired: { label: 'Retired', tone: 'slate', hint: 'No longer in use.' },
};
/** What each tool kind means, for the safety column. */
export const TOOL_KIND = {
read: { label: 'Read-only', gated: false },
write: { label: 'Writes', gated: true },
notify: { label: 'Messages people', gated: true },
event: { label: 'Internal event', gated: false },
};
const title = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
/** @param {any} a — an AgentView from /admin/ai/agents */
export function toUiAgent(a) {
return {
id: a.agentid,
name: a.name,
runtime: a.runtime,
status: a.status,
statusMeta: AGENT_STATUS[a.status] || { label: title(a.status), tone: 'slate', hint: '' },
badge: a.runtime === 'engine' ? 'AI_ENGINE AGENT' : 'CONSOLE AGENT',
description: a.purpose,
wakeon: a.wakeon,
classref: a.classref,
llmdecision: a.llmdecision,
hasAutonomyGate: Boolean(a.hasautonomygate),
autonomous: Boolean(a.autonomous),
model: a.model || '',
skillCount: a.skillcount ?? 0,
toolCount: a.toolcount ?? 0,
};
}
/**
* @param {any} s — a SkillView from /admin/ai/skills
* @param {Record<string, { name: string }>} agentsById — for the integration tag
*/
export function toUiSkill(s, agentsById = {}) {
return {
id: s.skillid,
agentId: s.agentid,
title: s.title,
category: (s.category || 'general').toUpperCase(),
description: s.description,
prompt: s.sampleprompt || null,
integration: agentsById[s.agentid]?.name || s.agentid,
source: s.source,
enabled: Boolean(s.enabled),
scope: 'agent',
tools: s.tools || [],
thresholds: s.thresholds || {},
thresholdsSchema: s.thresholdsschema || [],
version: s.version,
};
}
/**
* JSON-schema `properties` → the parameter rows the tool table renders.
* @param {any} schema
*/
export function schemaToParameters(schema) {
const props = schema?.properties || {};
const required = new Set(schema?.required || []);
return Object.entries(props).map(([name, p]) => ({
name,
type: p?.type || 'any',
description: p?.description || '',
required: required.has(name),
...(p && 'default' in p ? { default: p.default } : {}),
}));
}
/**
* @param {any} t — a ToolView from /admin/ai/tools
* @param {any[]} uiSkills — to name the skills that use the tool
*/
export function toUiTool(t, uiSkills = []) {
const users = uiSkills.filter((s) => s.tools.includes(t.toolname)).map((s) => s.title);
const kind = TOOL_KIND[t.kind] || { label: title(t.kind), gated: Boolean(t.requiresconfirmation) };
return {
name: t.toolname,
description: t.description,
kind: t.kind,
category: kind.label,
parentSkill: users.join(', '),
requiresConfirmation: Boolean(t.requiresconfirmation),
target: t.target,
implementedAt: t.implementedat,
parameters: schemaToParameters(t.inputschema),
};
}
/** Everything the page renders, from the three raw lists. */
export function buildStudioModel(rawAgents = [], rawSkills = [], rawTools = []) {
const agents = rawAgents.map(toUiAgent);
const agentsById = Object.fromEntries(agents.map((a) => [a.id, a]));
const skills = rawSkills.map((s) => toUiSkill(s, agentsById));
const tools = rawTools.map((t) => toUiTool(t, skills));
return { agents, agentsById, skills, tools };
}
/** Only the admin role may change the registry; the server enforces the same. */
export const canEditRegistry = (user) => String(user?.role || '').toLowerCase() === 'admin';
/**
* Model ids an engine agent may be pinned to. Empty means "the engine's own
* default" (LLM_MODEL in AI_engine). The server accepts any `claude-*` id.
* Haiku is left out on purpose: it takes no adaptive thinking or effort, so
* the engine runs it without them (core/llm.py request_params) — too weak
* for the stall and assignment decisions. A pin set elsewhere still shows.
*/
export const MODEL_OPTIONS = [
{ value: '', label: 'Engine default (LLM_MODEL)' },
{ value: 'claude-opus-5-5', label: 'Claude Opus 5.5' },
{ value: 'claude-sonnet-5-5', label: 'Claude Sonnet 5.5' },
{ value: 'claude-opus-4-8', label: 'Claude Opus 4.8' },
];

View File

@@ -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/);
});
});

View File

@@ -12,96 +12,80 @@ jest.mock('lucide-react', () =>
);
import Agents from '@/pages/doormile/agents/Agents';
import { SNAPSHOT_AT, SNAPSHOT_LABEL, networkStats } from '@/lib/agentNetwork';
/**
* The Agents page.
*
* It renders a hand-maintained table (lib/agentNetwork.js) rather than a live
* feed, so the risk here is not stale data — agentNetwork.test.js guards the
* table — it is that the page silently drops something. A detail panel that
* renders an empty string, or a card whose status has no colour, looks like a
* quiet page rather than a broken one.
* It is drawn from a hand-maintained snapshot (lib/agentNetwork.js) plus a
* scenario player (AgentFlowVisualizer) — neither is a feed from AI_engine,
* which exposes nothing this console can read. The risk these tests guard is
* the page PRESENTING that as live: a status board that looks real-time and is
* days old is worse than one that says how old it is.
*
* Rewritten 2026-09-29 for the flow-visualizer layout (the card grid these
* tests used to target is gone). The honesty checks were kept, not dropped.
*/
describe('the Agents page', () => {
it('lists all nine agents', () => {
it('says how old its figures are, as a machine-readable date', () => {
render(<Agents />);
['JARVIS', 'Dispatch', 'Exception', 'Express Dispatch', 'Order', 'Route Optimizer', 'Hub', 'Fleet', 'Customer'].forEach(
const stamp = screen.getByText(new RegExp(SNAPSHOT_LABEL));
expect(stamp.tagName).toBe('TIME');
expect(stamp).toHaveAttribute('dateTime', SNAPSHOT_AT);
expect(stamp).toHaveTextContent(/Snapshot/);
});
it('does not call itself real-time or live', () => {
const { container } = render(<Agents />);
expect(screen.queryByText(/Real-Time/i)).not.toBeInTheDocument();
expect(screen.queryByText(/^Live Activity$/i)).not.toBeInTheDocument();
// The pulsing "on air" dot is gone from the header.
expect(container.querySelector('h1').parentElement.querySelector('.animate-pulse')).toBeNull();
});
it('labels the activity panel as a simulation', () => {
render(<Agents />);
expect(screen.getByText('Sample Activity')).toBeInTheDocument();
expect(screen.getByText(/Simulation · not live data/)).toBeInTheDocument();
});
it('takes its status counts from the snapshot, not hard-coded numbers', () => {
render(<Agents />);
const s = networkStats();
expect(screen.getByText(`${s.working} Processing`)).toBeInTheDocument();
expect(screen.getByText(`${s.degraded} Warning`)).toBeInTheDocument();
expect(screen.getByText(`${s.total - s.working - s.degraded} Idle`)).toBeInTheDocument();
});
it('states that no autonomy gate is on, out of the agents that have one', () => {
// The page's central claim: the swarm observes and alerts, and does not
// write to the system. The denominator is gated agents, not all agents.
render(<Agents />);
const s = networkStats();
expect(s.autonomyOn).toBe(0);
expect(screen.getByText(`Autonomy gates on: 0 / ${s.gated}`)).toBeInTheDocument();
});
it('lists all nine agents in the flow', () => {
render(<Agents />);
['JARVIS Orchestrator', 'Order', 'Dispatch', 'Exception', 'Route Optimizer', 'Fleet', 'Hub', 'Express Dispatch', 'Customer'].forEach(
(name) => {
expect(screen.getAllByText(name).length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name })).toBeInTheDocument();
}
);
});
it('opens on Dispatch rather than an empty panel', () => {
// It is the only agent with anything to show. Landing on a blank detail
// panel reads as a broken page.
it('opens the technical record of the agent that was clicked', () => {
render(<Agents />);
expect(screen.getByText('DISPATCH_AGENT')).toBeInTheDocument();
expect(screen.getByText(/only agent doing real work/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Exception' }));
fireEvent.click(screen.getByRole('button', { name: /Technical Specs/ }));
expect(screen.getAllByText(/EXCEPTION_AGENT/).length).toBeGreaterThan(0);
});
it('swaps the whole detail panel when another agent is picked', () => {
it('shows the grouped error counts from the run log', () => {
render(<Agents />);
fireEvent.click(screen.getByRole('button', { name: /Exception/ }));
expect(screen.getByText('EXCEPTION_AGENT')).toBeInTheDocument();
expect(screen.getByText(/17,188 of the 18,146 lines/)).toBeInTheDocument();
// and the previous agent's record is gone, not merely scrolled away
expect(screen.queryByText('DISPATCH_AGENT')).not.toBeInTheDocument();
});
it('marks the selected card for a screen reader, not just with a colour', () => {
render(<Agents />);
const exception = screen.getByRole('button', { name: /Exception/ });
expect(exception).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(exception);
expect(exception).toHaveAttribute('aria-pressed', 'true');
});
it('says a task-driven agent listens to nothing, rather than leaving a blank', () => {
render(<Agents />);
fireEvent.click(screen.getByRole('button', { name: /Fleet/ }));
expect(screen.getByText(/runs only when JARVIS hands it a task/i)).toBeInTheDocument();
});
it('shows the pipeline, both halves of it', () => {
render(<Agents />);
expect(screen.getByText('doormile_backend')).toBeInTheDocument();
expect(screen.getByText('NATS JetStream')).toBeInTheDocument();
expect(screen.getByText('Event-driven')).toBeInTheDocument();
expect(screen.getByText('Task-driven')).toBeInTheDocument();
expect(screen.getByText('Alert, never act')).toBeInTheDocument();
});
it('states that nothing is switched on, in the KPI row', () => {
// The page's central claim: the swarm observes and alerts, and does not
// write to the system.
render(<Agents />);
expect(screen.getByText(/Gates on/)).toBeInTheDocument();
expect(screen.getByText(/All autonomy gates OFF/)).toBeInTheDocument();
});
it('admits the figures are a snapshot, not a feed', () => {
// A status board that looks live and is four days old is worse than one
// that says how old it is.
render(<Agents />);
expect(screen.getAllByText(/Last run · 16–20 Sep 2026/).length).toBeGreaterThan(0);
});
it('shows the repeated-error count on the log rows', () => {
render(<Agents />);
const errs = screen.getAllByText('×8,594');
expect(errs).toHaveLength(2);
});
it('every card carries a real trigger label, not undefined', () => {
const { container } = render(<Agents />);
const cards = container.querySelectorAll('button[aria-pressed]');
expect(cards).toHaveLength(9);
cards.forEach((card) => {
const text = within(card).getByText(/NATS event|NATS pull|JARVIS task|In-process/);
expect(text).toBeInTheDocument();
});
const diagnostics = screen.getByText('System Diagnostics').closest('div.flex-col');
expect(within(diagnostics).getByText('8,594 occurrences')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,221 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
/**
* Clients → Onboard client. The real page, hooks and validation run; only the
* HTTP functions are mocked, shaped like POST /admin/clients/onboard and
* GET /admin/clients/onboarded. The server's own gate and transaction are
* covered by doormile_backend's route tests and the live local run.
*/
// Radix's Switch measures itself; jsdom has no ResizeObserver.
beforeAll(() => {
global.ResizeObserver = global.ResizeObserver || class { observe() {} unobserve() {} disconnect() {} };
});
jest.mock('lucide-react', () =>
new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />) })
);
jest.mock('@/api/doormile', () => ({
onboardClient: jest.fn(),
getOnboardedClients: jest.fn(),
getOnboardingCities: jest.fn(),
}));
let mockAuth = { user: { email: 'admin@doormile.com', role: 'admin', tenantid: null }, isClient: false };
jest.mock('@/lib/AuthContext', () => ({ useAuth: () => mockAuth }));
import * as api from '@/api/doormile';
import ClientOnboarding from '@/pages/doormile/clients/ClientOnboarding';
import { canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateOnboarding } from '@/lib/clientOnboarding';
const CITIES = [
{ applocationid: 1, applocationname: 'Coimbatore', status: 'Active' },
{ applocationid: 2, applocationname: 'Hyderabad', status: 'Active' },
{ applocationid: 0, applocationname: 'All', status: 'Active' },
];
const renderPage = () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<ClientOnboarding />
</MemoryRouter>
</QueryClientProvider>
);
};
const fill = (label, value) => fireEvent.change(screen.getByLabelText(label, { exact: false }), { target: { value } });
const fillValidForm = async () => {
await screen.findByRole('option', { name: 'Coimbatore' });
fill('Company name', 'Acme Foods');
fill('Contact person', 'Priya Raman');
fill('Mobile number', '98765 43210');
fill('Login email', ' Ops@Acme.Example ');
fill('Operating city', '1');
fireEvent.change(document.getElementById('onboard-password'), { target: { value: 's3cure-pass' } });
fill('Confirm password', 's3cure-pass');
};
beforeEach(() => {
mockAuth = { user: { email: 'admin@doormile.com', role: 'admin', tenantid: null }, isClient: false };
jest.clearAllMocks();
api.getOnboardingCities.mockResolvedValue(CITIES);
api.getOnboardedClients.mockResolvedValue([
{ tenantid: 12, tenantname: 'Older Client', primaryemail: 'a@old.example', primarycontact: '9000000000', status: 'Active', loginemail: 'a@old.example', loginrole: 'manager', logincreatedat: '2026-09-28T10:00:00Z' },
]);
});
describe('Client onboarding page', () => {
it('onboards a client with a normalised payload and shows the new login once', async () => {
api.onboardClient.mockResolvedValue({
success: true,
data: {
tenant: { tenantid: 42, tenantname: 'Acme Foods', status: 'Active' },
login: { email: 'ops@acme.example', role: 'manager', userid: 7, tenantid: 42 },
},
});
renderPage();
await fillValidForm();
fireEvent.click(screen.getByRole('button', { name: /Onboard client/ }));
await screen.findByText('Acme Foods is onboarded');
expect(api.onboardClient.mock.calls[0][0]).toEqual({
companyname: 'Acme Foods',
contactname: 'Priya Raman',
email: 'ops@acme.example',
phone: '9876543210',
password: 's3cure-pass',
applocationid: 1,
requiredeliveryotp: false,
});
expect(screen.getByRole('status')).toHaveTextContent('ops@acme.example');
expect(screen.getByRole('status')).toHaveTextContent('only time the password is shown');
// The form is cleared so the password does not linger on screen.
expect(document.getElementById('onboard-password')).toHaveValue('');
});
it('lists clients that already have a console login, without the "All" pseudo-city', async () => {
renderPage();
expect(await screen.findByText('Older Client')).toBeInTheDocument();
expect(screen.getByText('a@old.example')).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'All' })).not.toBeInTheDocument();
});
it('refuses an incomplete form without calling the server', async () => {
renderPage();
await screen.findByRole('option', { name: 'Coimbatore' });
fireEvent.change(document.getElementById('onboard-password'), { target: { value: 's3cure-pass' } });
fill('Confirm password', 'different-1');
fireEvent.click(screen.getByRole('button', { name: /Onboard client/ }));
expect(await screen.findByText('Enter the company name')).toBeInTheDocument();
expect(screen.getByText('The passwords do not match')).toBeInTheDocument();
expect(screen.getByText("Choose the client's operating city")).toBeInTheDocument();
expect(api.onboardClient).not.toHaveBeenCalled();
});
it('puts a server conflict under the field it is about', async () => {
api.onboardClient.mockRejectedValue({ response: { status: 409, data: { success: false, message: 'this email already has a console login' } } });
renderPage();
await fillValidForm();
fireEvent.click(screen.getByRole('button', { name: /Onboard client/ }));
expect(await screen.findByText('this email already has a console login')).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('says so when the server refuses the login', async () => {
api.onboardClient.mockRejectedValue({ response: { status: 403, data: { success: false } } });
renderPage();
await fillValidForm();
fireEvent.click(screen.getByRole('button', { name: /Onboard client/ }));
expect(await screen.findByRole('alert')).toHaveTextContent('not allowed to onboard clients');
});
it('fills both password fields when generating one', async () => {
renderPage();
await screen.findByRole('option', { name: 'Coimbatore' });
fireEvent.click(screen.getByRole('button', { name: /Generate password/ }));
const pw = document.getElementById('onboard-password').value;
expect(pw).toHaveLength(14);
expect(screen.getByLabelText('Confirm password', { exact: false })).toHaveValue(pw);
});
it.each([
['another Doormile admin', { user: { email: 'suriya@doormile.com', role: 'admin' }, isClient: false }],
['the owner email on a client login', { user: { email: 'admin@doormile.com', role: 'admin', tenantid: 7 }, isClient: true }],
['the owner email as a manager', { user: { email: 'admin@doormile.com', role: 'manager' }, isClient: false }],
])('is closed to %s, and calls nothing', async (_name, auth) => {
mockAuth = auth;
renderPage();
expect(screen.getByText('Client onboarding is restricted')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Onboard client/ })).not.toBeInTheDocument();
await waitFor(() => expect(api.getOnboardedClients).not.toHaveBeenCalled());
expect(api.getOnboardingCities).not.toHaveBeenCalled();
});
});
describe('Client onboarding when the city list fails', () => {
it('explains a server without the onboarding endpoints and blocks submit', async () => {
api.getOnboardingCities.mockRejectedValue({ response: { status: 404, data: {} } });
renderPage();
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Client onboarding is not available right now');
expect(alert).toHaveTextContent('does not have client onboarding yet');
expect(screen.getByRole('option', { name: 'Cities could not be loaded' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Onboard client/ })).toBeDisabled();
});
it('retries on demand and recovers', async () => {
api.getOnboardingCities.mockRejectedValueOnce({ response: { status: 500, data: { message: 'failed to list cities' } } });
renderPage();
expect(await screen.findByRole('alert')).toHaveTextContent('failed to list cities');
api.getOnboardingCities.mockResolvedValue(CITIES);
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(await screen.findByRole('option', { name: 'Coimbatore' })).toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
expect(screen.getByRole('button', { name: /Onboard client/ })).not.toBeDisabled();
});
});
describe('client onboarding helpers', () => {
it('allows only the owner email, as Doormile staff admin, case-insensitively', () => {
expect(canOnboardClients({ email: 'Admin@Doormile.com', role: 'admin' }, false)).toBe(true);
expect(canOnboardClients({ email: 'admin@doormile.com', roleid: 1 }, false)).toBe(true);
expect(canOnboardClients({ email: 'admin@doormile.com', role: 'admin' }, true)).toBe(false);
expect(canOnboardClients({ email: 'suriya@doormile.com', role: 'admin' }, false)).toBe(false);
expect(canOnboardClients(null, false)).toBe(false);
});
it('generates passwords the validation accepts', () => {
for (let i = 0; i < 50; i += 1) {
const pw = generateClientPassword();
expect(pw).toMatch(/\d/);
expect(pw).toMatch(/[a-z]/i);
const errors = validateOnboarding({
companyname: 'Acme', contactname: 'Priya', email: 'a@b.co', phone: '9876543210', applocationid: 1, password: pw, confirm: pw,
});
expect(errors).toEqual({});
}
});
it('explains each failure plainly', () => {
expect(onboardingUnavailableMessage({ response: { status: 404 } })).toMatch(/deployed/);
expect(onboardingUnavailableMessage({ response: { status: 403 } })).toMatch(/admin@doormile.com/);
expect(onboardingUnavailableMessage({ response: { status: 401 } })).toMatch(/Sign in again/);
expect(onboardingUnavailableMessage(new Error('Network Error'))).toMatch(/Could not reach/);
});
it('mirrors the server rules', () => {
const base = { companyname: 'Acme', contactname: 'Priya', email: 'a@b.co', phone: '9876543210', applocationid: 1, password: 'longenough1', confirm: 'longenough1' };
expect(validateOnboarding({ ...base, phone: '5876543210' }).phone).toBeTruthy();
expect(validateOnboarding({ ...base, email: 'Ops <a@b.co>' }).email).toBeTruthy();
expect(validateOnboarding({ ...base, password: 'a@b.co', confirm: 'a@b.co' }).password).toBeTruthy();
expect(validateOnboarding({ ...base, password: 'short', confirm: 'short' }).password).toBeTruthy();
});
});

View File

@@ -0,0 +1,108 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
/**
* Create Order → Order Summary: driving distance and duration for short legs.
*
* Pickup and drop on the same address showed "1 km" and "15 mins". OSRM rounds
* to whole km (a 120 m leg is 0 km), the page then did `Math.max(1, d || 1)`
* and `minutes || 15`, so every leg under 500 m read as 1 km / 15 mins. The
* card must show the real road length in metres and "< 1 min".
*/
jest.mock('lucide-react', () =>
new Proxy({}, {
get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />),
})
);
jest.mock('leaflet', () => ({ __esModule: true, default: { DivIcon: function DivIcon() {} } }));
jest.mock('react-leaflet', () => ({
MapContainer: () => null, Marker: () => null, Polyline: () => null, TileLayer: () => null, useMap: () => ({}),
}));
// The autocomplete is replaced by a button that "selects" one fixed place, so
// the test drives the real handlePlaceSelect → route effect → summary card.
const RS_PURAM = { latitude: 11.0086, longitude: 76.9502, formatted_address: 'RS Puram, Coimbatore, Tamil Nadu, 641002' };
jest.mock('@/components/doormile/AddressAutocomplete', () => ({
__esModule: true,
default: ({ id, onPlaceSelected }) => (
<button type="button" data-testid={`select-${id}`} onClick={() => onPlaceSelected(RS_PURAM)}>select</button>
),
}));
jest.mock('@/components/doormile/MapPinPickerModal', () => ({ __esModule: true, default: () => null }));
jest.mock('@/components/doormile/HubLocationSelect', () => ({ HubLocationSelect: () => null }));
jest.mock('@/lib/doormileHooks', () => ({ useHubs: () => ({ data: [] }) }));
jest.mock('@/lib/geocodingService', () => ({ buildCityScope: () => null, geocodeAddress: jest.fn(), saveRecentAddress: jest.fn() }));
jest.mock('@/components/third-party/OpenToast', () => ({ OpenToast: jest.fn() }));
jest.mock('@/api/doormile/endpoints', () => ({
createExpressBooking: jest.fn(),
createTenantCustomer: jest.fn(),
getAdminCustomers: jest.fn(() => Promise.resolve([])),
getAdminPricing: jest.fn(() => Promise.resolve([])),
getAdminTenants: jest.fn(() => Promise.resolve([])),
getTenantCustomers: jest.fn(() => Promise.resolve([])),
getTenantLocations: jest.fn(() => Promise.resolve([])),
}));
import CreateOrder from '@/pages/doormile/orders/CreateOrder';
const osrm = (meters, seconds) => ({
ok: true,
json: async () => ({ routes: [{ distance: meters, duration: seconds, geometry: { coordinates: [] } }] }),
});
const renderPage = () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<CreateOrder />
</MemoryRouter>
</QueryClientProvider>
);
};
// The value box sits directly under its label in the summary card.
const metric = (label) => screen.getByText(label).nextElementSibling.textContent;
const pinBothEnds = () => {
fireEvent.click(screen.getByTestId('select-pickup-address'));
fireEvent.click(screen.getByTestId('select-drop-address'));
};
describe('Create Order — route summary for short legs', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
it('shows 0 m and "< 1 min" when pickup and drop are the same address — not 1 km / 15 mins', async () => {
global.fetch.mockResolvedValue(osrm(0, 0));
renderPage();
pinBothEnds();
await waitFor(() => expect(metric('Driving Dist.')).toBe('0 m'));
expect(metric('Est. Duration')).toBe('< 1 min');
expect(screen.getByText('0 m road path')).toBeInTheDocument();
expect(screen.queryByText('1 km')).not.toBeInTheDocument();
expect(screen.queryByText('15 mins')).not.toBeInTheDocument();
});
it('shows a ~140 m leg in metres', async () => {
global.fetch.mockResolvedValue(osrm(142, 25));
renderPage();
pinBothEnds();
await waitFor(() => expect(metric('Driving Dist.')).toBe('140 m'));
expect(metric('Est. Duration')).toBe('< 1 min');
});
it('still shows longer legs in km with one decimal and real minutes', async () => {
global.fetch.mockResolvedValue(osrm(1908, 130));
renderPage();
pinBothEnds();
await waitFor(() => expect(metric('Driving Dist.')).toBe('1.9 km'));
expect(metric('Est. Duration')).toBe('2 mins');
});
});

View File

@@ -29,6 +29,9 @@ jest.mock('@/components/doormile/HubLocationSelect', () => ({ HubLocationSelect:
jest.mock('@/lib/doormileHooks', () => ({ useHubs: () => ({ data: [] }) }));
jest.mock('@/lib/geocodingService', () => ({ geocodeAddress: jest.fn(), saveRecentAddress: jest.fn() }));
jest.mock('@/lib/distance', () => ({
// The formatters are pure — keep the real ones so the summary card renders.
formatRouteDistance: jest.requireActual('@/lib/distance').formatRouteDistance,
formatRouteDuration: jest.requireActual('@/lib/distance').formatRouteDuration,
calculateDrivingDistance: jest.fn(() => Promise.resolve(null)),
calculateDrivingRoute: jest.fn(() => Promise.resolve(null)),
calculateTotalCharge: jest.fn(() => 0),

View File

@@ -0,0 +1,66 @@
import { AgentFactory } from '@/lib/assistant/agent/AgentFactory';
import { SkillRegistry } from '@/lib/assistant/skills/SkillRegistry';
describe('AgentFactory', () => {
beforeEach(() => {
SkillRegistry.clearRegistryConfig();
});
it('synthesizes an agent from active skills with instructions and tools', () => {
const agent = AgentFactory.synthesizeDefaultAgent();
expect(agent.name).toBe('Doormile Fleet Orchestrator');
expect(agent.activeSkillCount).toBeGreaterThanOrEqual(5);
expect(agent.systemPrompt).toContain('Doormile Autonomous Logistics Fleet Orchestrator');
expect(agent.tools.length).toBeGreaterThan(0);
});
it('evaluates telemetry findings across active skills', () => {
const agent = AgentFactory.synthesizeDefaultAgent();
const sampleRows = [
{
bookingid: 501,
bookingno: 'BK-501',
orderstatus: 'pending',
expecteddeliverytime: '2026-08-31 10:00:00', // overdue compared to 12:00:00
orderdate: '2026-08-31 09:00:00'
}
];
const findings = agent.evaluateTelemetry(sampleRows, '2026-08-31 12:00:00');
expect(findings.length).toBeGreaterThan(0);
expect(findings.some((f) => f.id === 'sla_breached')).toBe(true);
});
it('omits disabled skills from telemetry evaluation', () => {
SkillRegistry.applyRegistryConfig([{ skillid: 'skill_sla_guardian', enabled: false, thresholds: {} }]);
const agent = AgentFactory.synthesizeDefaultAgent();
const sampleRows = [
{
bookingid: 501,
bookingno: 'BK-501',
orderstatus: 'pending',
expecteddeliverytime: '2026-08-31 10:00:00',
orderdate: '2026-08-31 09:00:00'
}
];
const findings = agent.evaluateTelemetry(sampleRows, '2026-08-31 12:00:00');
expect(findings.some((f) => f.id === 'sla_breached')).toBe(false);
});
it('executes active skill tools', async () => {
const agent = AgentFactory.synthesizeDefaultAgent();
const result = await agent.executeSkillTool('reassign_sla_critical_order', {
bookingId: 999,
targetMilerId: 42,
reason: 'Urgent priority'
});
expect(result.success).toBe(true);
expect(result.proposal.action).toBe('reassign_miler');
expect(result.proposal.bookingId).toBe(999);
});
});

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import AgentOperationsBanner from '@/components/doormile/AgentOperationsBanner';
import * as scanModule from '@/lib/assistant/scan';
import { AgentFactory } from '@/lib/assistant/agent/AgentFactory';
import { SkillRegistry } from '@/lib/assistant/skills/SkillRegistry';
jest.mock('lucide-react', () =>
new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />) })
);
jest.mock('@/lib/assistant/scan');
jest.mock('@/lib/assistant/agent/AgentFactory');
const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
function Where() {
const loc = useLocation();
return <div data-testid="where">{loc.pathname + loc.search}</div>;
}
const renderBanner = (props = {}) =>
render(
<QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={['/doormile/exceptions']}>
<Routes>
<Route path="*" element={<><AgentOperationsBanner {...props} /><Where /></>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
);
const SLA_FINDING = {
id: 'sla_breached',
skillId: 'skill_sla_guardian',
severity: 'critical',
title: '1 parcel is past the promised delivery time',
why: 'Estimated delivery time has passed.',
count: 1,
rows: [{ bookingid: 101, bookingno: 'BK-101' }],
detail: ['BK-101 — 35 min overdue'],
proposal: {
label: 'Notify the assigned riders',
tool: 'notifyRider',
scope: [{ bookingid: 101 }],
blastRadius: 'Sends a message per rider.',
},
};
describe('AgentOperationsBanner', () => {
beforeEach(() => {
jest.clearAllMocks();
SkillRegistry.clearRegistryConfig();
});
it('says nothing is flagged, naming how many skills looked', async () => {
scanModule.scanBookings.mockResolvedValue({ rows: [], truncated: false });
AgentFactory.synthesizeDefaultAgent.mockReturnValue({ evaluateTelemetry: () => [] });
renderBanner();
await waitFor(() => {
expect(screen.getByText(/Nothing flagged by the 5 enabled skills/i)).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /Configure skills/i })).toBeInTheDocument();
});
it('renders remediation cards when findings are detected', async () => {
scanModule.scanBookings.mockResolvedValue({ rows: [{ bookingid: 101 }], truncated: false });
AgentFactory.synthesizeDefaultAgent.mockReturnValue({ evaluateTelemetry: () => [SLA_FINDING] });
renderBanner({ title: 'Custom Title' });
await waitFor(() => {
expect(screen.getByText(/Custom Title/i)).toBeInTheDocument();
expect(screen.getByText(/1 parcel is past the promised delivery time/i)).toBeInTheDocument();
});
});
it('filters findings by category', async () => {
scanModule.scanBookings.mockResolvedValue({ rows: [{ bookingid: 101 }], truncated: false });
AgentFactory.synthesizeDefaultAgent.mockReturnValue({
evaluateTelemetry: () => [
{ ...SLA_FINDING, proposal: undefined },
{
id: 'rider_critical_battery',
skillId: 'skill_rider_battery_safety', // category: rider_safety
severity: 'warning',
title: 'Rider battery critical',
why: 'Low battery',
count: 1,
rows: [{ bookingid: 102 }],
},
],
});
renderBanner({ category: 'rider_safety', title: 'Rider Safety Only' });
await waitFor(() => {
expect(screen.getByText(/Rider Safety Only/i)).toBeInTheDocument();
expect(screen.getByText(/Rider battery critical/i)).toBeInTheDocument();
expect(screen.queryByText(/1 parcel is past the promised delivery time/i)).toBeNull();
});
});
// The branch opened a localStorage-only settings modal here. Settings now
// live in the agent registry, edited on one page.
it('"Configure skills" goes to Settings → Skills & Tools', async () => {
scanModule.scanBookings.mockResolvedValue({ rows: [], truncated: false });
AgentFactory.synthesizeDefaultAgent.mockReturnValue({ evaluateTelemetry: () => [] });
renderBanner();
fireEvent.click(await screen.findByRole('button', { name: /Configure skills/i }));
expect(screen.getByTestId('where')).toHaveTextContent('/doormile/settings?tab=skills');
});
it('says when it is running on default thresholds, and stops once the registry answers', async () => {
scanModule.scanBookings.mockResolvedValue({ rows: [], truncated: false });
AgentFactory.synthesizeDefaultAgent.mockReturnValue({ evaluateTelemetry: () => [] });
renderBanner();
expect(await screen.findByText(/Running on default thresholds/i)).toBeInTheDocument();
SkillRegistry.applyRegistryConfig([{ skillid: 'skill_sla_guardian', enabled: true, thresholds: {} }]);
await waitFor(() => expect(screen.queryByText(/Running on default thresholds/i)).toBeNull());
});
it('never calls anything autonomous', async () => {
scanModule.scanBookings.mockResolvedValue({ rows: [], truncated: false });
AgentFactory.synthesizeDefaultAgent.mockReturnValue({ evaluateTelemetry: () => [SLA_FINDING] });
renderBanner();
await screen.findByText(/1 parcel is past the promised delivery time/i);
expect(screen.queryByText(/autonomous/i)).toBeNull();
expect(screen.queryByText(/self-healing/i)).toBeNull();
});
});

View File

@@ -0,0 +1,82 @@
import { SkillRegistry, DEFAULT_SKILLS } from '@/lib/assistant/skills/SkillRegistry';
/**
* The SkillRegistry joins the rules (code) with the settings (the backend
* agent registry, /admin/ai/skills). Rows below are shaped exactly like that
* endpoint's response.
*/
const row = (skillid, enabled, thresholds = {}) => ({ skillid, enabled, thresholds });
describe('SkillRegistry', () => {
beforeEach(() => {
SkillRegistry.clearRegistryConfig();
});
it('registers the eight built-in logistics skills', () => {
const ids = SkillRegistry.getAllSkills().map((s) => s.id);
expect(ids).toHaveLength(8);
expect(ids).toEqual(DEFAULT_SKILLS.map((s) => s.id));
expect(ids).toContain('skill_sla_guardian');
expect(ids).toContain('skill_rider_battery_safety');
});
it('runs on code defaults until the registry answers, and says so', () => {
expect(SkillRegistry.getSource()).toBe('defaults');
const sla = SkillRegistry.getSkill('skill_sla_guardian');
expect(sla.enabled).toBe(true);
expect(sla.effectiveThresholds.slaRiskWindowMin.currentValue).toBe(45);
});
it('takes enabled and thresholds from the registry', () => {
SkillRegistry.applyRegistryConfig([
row('skill_fleet_balancer', false),
row('skill_sla_guardian', true, { slaRiskWindowMin: 30 })
]);
expect(SkillRegistry.getSource()).toBe('registry');
expect(SkillRegistry.getSkill('skill_fleet_balancer').enabled).toBe(false);
expect(SkillRegistry.getSkill('skill_sla_guardian').effectiveThresholds.slaRiskWindowMin.currentValue).toBe(30);
// A threshold the registry did not send keeps its default.
expect(SkillRegistry.getSkill('skill_sla_guardian').effectiveThresholds.unassignedAgingMin.currentValue).toBe(60);
});
it('can switch on a skill that is off by default', () => {
SkillRegistry.applyRegistryConfig([row('skill_cash_exposure', true)]);
expect(SkillRegistry.getSkill('skill_cash_exposure').enabled).toBe(true);
});
it('ignores an out-of-range stored value rather than running on it', () => {
SkillRegistry.applyRegistryConfig([row('skill_doorstep_stall', true, { arrivedStalledMin: 999 })]);
expect(SkillRegistry.getSkill('skill_doorstep_stall').effectiveThresholds.arrivedStalledMin.currentValue).toBe(20);
});
it('ignores registry rows for skills this console does not implement', () => {
SkillRegistry.applyRegistryConfig([row('stall_response', false), row('custom_night_shift_watch', true)]);
expect(SkillRegistry.getAllSkills()).toHaveLength(8);
expect(SkillRegistry.getSkill('stall_response')).toBeUndefined();
});
it('goes back to defaults when the registry is lost', () => {
SkillRegistry.applyRegistryConfig([row('skill_sla_guardian', false, { slaRiskWindowMin: 15 })]);
SkillRegistry.clearRegistryConfig();
const sla = SkillRegistry.getSkill('skill_sla_guardian');
expect(SkillRegistry.getSource()).toBe('defaults');
expect(sla.enabled).toBe(true);
expect(sla.effectiveThresholds.slaRiskWindowMin.currentValue).toBe(45);
});
it('keeps nothing in browser storage', () => {
localStorage.clear();
SkillRegistry.applyRegistryConfig([row('skill_sla_guardian', false)]);
expect(localStorage.length).toBe(0);
});
it('notifies subscribers when the registry settings change', () => {
const listener = jest.fn();
const unsubscribe = SkillRegistry.subscribe(listener);
SkillRegistry.applyRegistryConfig([row('skill_doorstep_stall', false)]);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener.mock.calls[0][0].find((s) => s.id === 'skill_doorstep_stall').enabled).toBe(false);
unsubscribe();
});
});

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import SlaRemediationCard from '@/components/doormile/SlaRemediationCard';
// lucide-react ships ESM Jest does not transform; every component test here stubs it.
jest.mock('lucide-react', () =>
new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />) })
);
jest.mock('@/api/doormile/notify', () => ({
OpenToast: jest.fn()
}));
describe('SlaRemediationCard Component', () => {
const sampleFinding = {
title: '3 orders overdue in RS Puram',
why: 'Parcels have exceeded their promised delivery SLA window by >45 minutes.',
severity: 'critical',
count: 3,
proposal: {
label: 'Split between Karthik & Suresh',
blastRadius: 'Will transfer 2 orders to Karthik and 1 order to Suresh.'
}
};
it('renders title, explanation, and proposal details properly', () => {
render(<SlaRemediationCard finding={sampleFinding} />);
expect(screen.getByText('3 orders overdue in RS Puram')).toBeInTheDocument();
expect(screen.getByText(/Parcels have exceeded their promised delivery SLA window/)).toBeInTheDocument();
expect(screen.getByText('Needs attention now')).toBeInTheDocument();
expect(screen.getByText('3 Orders')).toBeInTheDocument();
expect(screen.getAllByText('Split between Karthik & Suresh').length).toBeGreaterThanOrEqual(1);
});
it('triggers onRemediate callback when Apply button is clicked', async () => {
const handleRemediate = jest.fn(async () => {});
render(<SlaRemediationCard finding={sampleFinding} onRemediate={handleRemediate} />);
const button = screen.getByRole('button', { name: /Split between Karthik & Suresh/i });
fireEvent.click(button);
await waitFor(() => {
expect(handleRemediate).toHaveBeenCalledTimes(1);
expect(handleRemediate).toHaveBeenCalledWith(sampleFinding);
expect(screen.getByText('Action sent')).toBeInTheDocument();
});
});
it('returns null if no finding is passed', () => {
const { container } = render(<SlaRemediationCard finding={null} />);
expect(container.firstChild).toBeNull();
});
});
describe('SlaRemediationCard — never claims an action it did not perform', () => {
const { OpenToast } = require('@/api/doormile/notify');
const finding = {
title: '1 rider stalled at door',
why: 'Rider stamped arrival but delivery has not completed.',
severity: 'critical',
count: 1,
proposal: {
label: 'Message waiting riders',
blastRadius: 'Sends an assistance check-in to stalled riders.'
}
};
beforeEach(() => {
OpenToast.mockClear();
});
// The bug: with no onRemediate the handler fell through to a 600ms timer and
// then reported "Remediation executed" with a green card. No caller has ever
// passed onRemediate, so every click on the Exceptions page took that branch
// and told the operator a rider had been messaged when nothing was sent.
it('does not report success when no executor is wired', async () => {
render(<SlaRemediationCard finding={finding} />);
const button = screen.getByRole('button', { name: /review only/i });
expect(button).toBeDisabled();
fireEvent.click(button);
await waitFor(() => {
expect(OpenToast).not.toHaveBeenCalled();
});
expect(screen.queryByText(/remediation executed/i)).not.toBeInTheDocument();
});
it('labels the action as review-only rather than offering a fix', () => {
render(<SlaRemediationCard finding={finding} />);
expect(screen.getByRole('button', { name: /review only/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /message waiting riders/i })).not.toBeInTheDocument();
});
it('executes and reports success only when an executor IS supplied', async () => {
const onRemediate = jest.fn().mockResolvedValue({ ok: true, message: 'Notified 1 rider.' });
render(<SlaRemediationCard finding={finding} onRemediate={onRemediate} />);
const button = screen.getByRole('button', { name: /message waiting riders/i });
expect(button).toBeEnabled();
fireEvent.click(button);
await waitFor(() => {
expect(onRemediate).toHaveBeenCalledWith(finding);
// (message, variant) — the branch passed ('success', msg), which rendered a RED error toast reading "success".
// The executor's own message is shown, so a partial success stays visible.
expect(OpenToast).toHaveBeenCalledWith('Notified 1 rider.', 'success');
});
});
it('reports failure honestly when the executor throws', async () => {
const onRemediate = jest.fn().mockRejectedValue(new Error('network'));
render(<SlaRemediationCard finding={finding} onRemediate={onRemediate} />);
fireEvent.click(screen.getByRole('button', { name: /message waiting riders/i }));
await waitFor(() => {
expect(OpenToast).toHaveBeenCalledWith('network', 'error');
});
expect(OpenToast).not.toHaveBeenCalledWith(expect.anything(), 'success');
});
});

View File

@@ -0,0 +1,200 @@
jest.mock('@/api/doormile', () => ({
__esModule: true,
getMilers: jest.fn(),
buildMilerLookup: jest.fn(),
notifyRider: jest.fn(),
batchAssignBookings: jest.fn()
}));
const api = require('@/api/doormile');
const {
executeProposal,
canExecuteProposal,
executableTools
} = require('@/lib/assistant/agent/actions');
// Rows as findings carry them: mileruserid, never milerprofileid.
const row = (over = {}) => ({ bookingid: 1, orderid: 'DM-BK-001', userid: 7, ridername: 'Suresh', ...over });
const finding = (tool, scope, severity = 'critical') => ({
id: 'stalled_at_door',
severity,
proposal: { label: 'Message waiting riders', tool, scope, blastRadius: 'Sends a check-in.' }
});
// The real bridge's shape: Maps keyed by string id.
const lookupOf = (milers) => ({
byUserId: new Map(milers.map((m) => [String(m.userid), m])),
byProfileId: new Map(milers.map((m) => [String(m.milerprofileid), m])),
byName: new Map()
});
beforeEach(() => {
api.getMilers.mockResolvedValue([{ userid: 7, milerprofileid: 99, displayname: 'Suresh' }]);
api.buildMilerLookup.mockImplementation(lookupOf);
api.notifyRider.mockResolvedValue({ success: true });
api.batchAssignBookings.mockResolvedValue({ success: true });
});
describe('which proposals can be executed', () => {
it('only verbs with a real endpoint behind them', () => {
expect(executableTools().sort()).toEqual(['notifyRider']);
});
it('canExecuteProposal is false for the skills tools with no endpoint', () => {
['trigger_auto_dispatch', 'enforce_cash_handoff', 'enforce_otp_verification', 'alert_low_battery_rider']
.forEach((tool) => {
expect(canExecuteProposal(finding(tool, [row()]))).toBe(false);
});
});
// Resolving quietly would let a caller mistake "nothing happened" for success.
it('throws rather than silently doing nothing', async () => {
await expect(executeProposal(finding('enforce_cash_handoff', [row()]))).rejects.toThrow(/review-only/i);
});
});
describe('notifyRider', () => {
// The trap: /admin/milers/:id/notify keys off milerprofileid, findings carry
// mileruserid. Both are small integers on the same record, and using the
// wrong one pages a different rider or 404s — silently, either way.
it('translates mileruserid to milerprofileid before pushing', async () => {
await executeProposal(finding('notifyRider', [row({ userid: 7 })]));
expect(api.notifyRider).toHaveBeenCalledTimes(1);
expect(api.notifyRider.mock.calls[0][0]).toBe(99); // profile id, NOT 7
});
it('fetches the miler list once for the whole batch', async () => {
await executeProposal(finding('notifyRider', [row({ bookingid: 1 }), row({ bookingid: 2 }), row({ bookingid: 3 })]));
expect(api.getMilers).toHaveBeenCalledTimes(1);
expect(api.notifyRider).toHaveBeenCalledTimes(3);
});
// A rider with no registered device cannot be pushed to. Saying nothing would
// leave the operator assuming a phone buzzed.
it('names riders it could not reach instead of skipping them', async () => {
api.getMilers.mockResolvedValue([{ userid: 7, milerprofileid: undefined, displayname: 'Suresh' }]);
const res = await executeProposal(finding('notifyRider', [row({ userid: 7 })]));
expect(api.notifyRider).not.toHaveBeenCalled();
expect(res.ok).toBe(false);
expect(res.unreachable[0]).toMatch(/no device registered/i);
});
it('reports a partial success as partial, naming the failures', async () => {
api.getMilers.mockResolvedValue([
{ userid: 7, milerprofileid: 99 },
{ userid: 8, milerprofileid: 100 }
]);
api.notifyRider.mockImplementation((id) => (id === 100 ? Promise.reject(new Error('device offline')) : Promise.resolve()));
const res = await executeProposal(
finding('notifyRider', [row({ bookingid: 1, userid: 7 }), row({ bookingid: 2, orderid: 'DM-BK-002', userid: 8 })])
);
expect(res.ok).toBe(true); // one did land
expect(res.notified).toEqual(['DM-BK-001']);
expect(res.message).toMatch(/Notified 1 rider/);
expect(res.message).toMatch(/Could not reach 1/);
expect(res.message).toMatch(/DM-BK-002/);
});
it('reports failure when nothing landed at all', async () => {
api.notifyRider.mockRejectedValue(new Error('network'));
const res = await executeProposal(finding('notifyRider', [row()]));
expect(res.ok).toBe(false);
expect(res.notified).toEqual([]);
});
it('refuses when no row carries a rider', async () => {
const res = await executeProposal(finding('notifyRider', [row({ userid: undefined })]));
expect(res.ok).toBe(false);
expect(res.message).toMatch(/assigned rider to notify/i);
expect(api.notifyRider).not.toHaveBeenCalled();
});
it('escalates the wording for critical findings', async () => {
await executeProposal(finding('notifyRider', [row()], 'critical'));
expect(api.notifyRider.mock.calls[0][2]).toMatch(/urgent/i);
api.notifyRider.mockClear();
await executeProposal(finding('notifyRider', [row()], 'warning'));
expect(api.notifyRider.mock.calls[0][2]).not.toMatch(/urgent/i);
});
it('records a source call per push so the operator can verify', async () => {
const res = await executeProposal(finding('notifyRider', [row()]));
expect(res.sourceCalls[0].target).toMatch(/\/admin\/milers\/99\/notify/);
expect(res.sourceCalls[0].status).toBe('complete');
});
});
describe('assignMiler', () => {
// Deliberately review-only in the console. The branch executed it through
// POST /hub/bookings/batch-assign, which is behind HubStaffAuth (roleid 6
// only) — every admin/manager/executive click would 403. Until an admin
// batch-assign route exists, the card must say "Review only", not fail.
it('is not executable, so its card renders "Review only"', () => {
expect(canExecuteProposal(finding('assignMiler', [row({ bookingid: 1 })]))).toBe(false);
});
it('throws instead of pretending, and never calls the hub-only endpoint', async () => {
await expect(executeProposal(finding('assignMiler', [row()]))).rejects.toThrow(/review-only/);
expect(api.batchAssignBookings).not.toHaveBeenCalled();
});
});
// The gap the tests above have: they mock buildMilerLookup, so the id
// translation is only ever checked against a stand-in. This uses the REAL one.
//
// It matters because the whole notify path hinges on it. mileruserid and
// milerprofileid are both small integers on the same record, so a translation
// that quietly returned the input would look identical in every mocked test
// and would page the wrong rider in production.
describe('notifyRider against the real buildMilerLookup', () => {
const realLookup = jest.requireActual('@/api/doormile/queries').buildMilerLookup;
beforeEach(() => {
api.buildMilerLookup.mockImplementation(realLookup);
api.getMilers.mockResolvedValue([
{ userid: 8, milerprofileid: 501, displayname: 'Kumar', phone: '900' },
{ userid: 7, milerprofileid: 499, displayname: 'Suresh', phone: '901' }
]);
api.notifyRider.mockResolvedValue({ success: true });
});
it('resolves mileruserid 8 to milerprofileid 501, not to 8', async () => {
await executeProposal(finding('notifyRider', [row({ userid: 8, ridername: 'Kumar' })]));
expect(api.notifyRider).toHaveBeenCalledTimes(1);
const pushedTo = api.notifyRider.mock.calls[0][0];
expect(pushedTo).toBe(501);
expect(pushedTo).not.toBe(8); // the failure mode, stated explicitly
});
it('pushes each rider to their own profile id', async () => {
await executeProposal(
finding('notifyRider', [
row({ bookingid: 1, userid: 8, ridername: 'Kumar' }),
row({ bookingid: 2, orderid: 'DM-BK-002', userid: 7, ridername: 'Suresh' })
])
);
expect(api.notifyRider.mock.calls.map((c) => c[0]).sort()).toEqual([499, 501]);
});
// A rider who has left the fleet is not in the list any more. That must be
// reported, not crash the whole batch and not silently push to undefined.
it('reports a rider missing from the fleet list instead of throwing', async () => {
const res = await executeProposal(
finding('notifyRider', [
row({ bookingid: 1, userid: 8, ridername: 'Kumar' }),
row({ bookingid: 2, orderid: 'DM-BK-002', userid: 404, ridername: 'Gone' })
])
);
expect(api.notifyRider).toHaveBeenCalledTimes(1);
expect(api.notifyRider.mock.calls[0][0]).toBe(501);
expect(res.ok).toBe(true);
expect(res.unreachable.join(' ')).toMatch(/DM-BK-002/);
});
});

View File

@@ -0,0 +1,141 @@
import { buildBriefing } from '@/lib/assistant/agent/briefing';
import { toAgentRow } from '@/lib/assistant/agent/normalise';
const NOW = '2026-08-29 14:00:00';
// Raw rows as `/admin/bookings` actually returns them — backend status enums,
// createdat, assignedmileruserid, and the promise buried in serviceoptions.
// The agent's rules read none of these field names directly, which is the whole
// reason the adapter exists.
const rawBooking = (over = {}) => ({
bookingid: 1,
bookingno: 'DM-BK-001',
status: 'Pending_Pickup',
createdat: '2026-08-29 13:50:00',
serviceoptions: [{ estimateddeliveryat: undefined }],
...over
});
describe('toAgentRow', () => {
it('derives the normalised status from the backend enum', () => {
expect(toAgentRow(rawBooking({ status: 'Pending_Pickup' })).orderstatus).toBe('pending');
});
it('reads the promised time out of the first service option', () => {
const row = toAgentRow(rawBooking({ serviceoptions: [{ estimateddeliveryat: '2026-08-29 12:00:00' }] }));
expect(row.expecteddeliverytime).toBe('2026-08-29 12:00:00');
});
it('leaves the promise undefined when there is no service option', () => {
expect(toAgentRow(rawBooking({ serviceoptions: [] })).expecteddeliverytime).toBeUndefined();
expect(toAgentRow(rawBooking({ serviceoptions: undefined })).expecteddeliverytime).toBeUndefined();
});
it('maps the rider id off assignedmileruserid', () => {
expect(toAgentRow(rawBooking({ assignedmileruserid: 7 })).userid).toBe(7);
});
it('uses createdat as the order date', () => {
expect(toAgentRow(rawBooking()).orderdate).toBe('2026-08-29 13:50:00');
});
it('falls back to a hashed id when the booking has no number', () => {
expect(toAgentRow(rawBooking({ bookingno: undefined, bookingid: 42 })).orderid).toBe('#42');
});
it('does not throw on an empty row', () => {
expect(() => toAgentRow({})).not.toThrow();
});
});
describe('buildBriefing over raw scan rows', () => {
// The regression this file exists for. The rules were written against the
// normalised Deliveries shape while the scan returns raw rows, so every rule
// read undefined and the agent reported an all-clear board no matter what was
// actually wrong. It failed silently and linted clean.
it('finds a breach in a RAW row, not just a normalised one', () => {
const scan = {
truncated: false,
rows: [
rawBooking({
status: 'Pickup_Scheduled',
serviceoptions: [{ estimateddeliveryat: '2026-08-29 12:00:00' }]
})
]
};
const briefing = buildBriefing(scan, NOW);
expect(briefing.findings.map((f) => f.id)).toContain('sla_breached');
expect(briefing.headline).not.toMatch(/Nothing needs attention/i);
});
it('finds aging unassigned work in a RAW row', () => {
const scan = { truncated: false, rows: [rawBooking({ createdat: '2026-08-29 11:00:00' })] };
expect(buildBriefing(scan, NOW).findings.map((f) => f.id)).toContain('unassigned_aging');
});
it('reports an all-clear only when the board really is clear', () => {
const scan = {
truncated: false,
rows: [rawBooking({ status: 'Delivered', createdat: '2026-08-29 13:55:00' })]
};
const briefing = buildBriefing(scan, NOW);
expect(briefing.findings).toEqual([]);
expect(briefing.headline).toMatch(/Nothing needs attention/i);
});
// A truncated scan is a floor, not a total — the assistant's standing
// contract. Counts must say so rather than implying completeness.
it('marks counts as a floor when the scan was truncated', () => {
const scan = {
truncated: true,
rows: [
rawBooking({ status: 'Pickup_Scheduled', serviceoptions: [{ estimateddeliveryat: '2026-08-29 12:00:00' }] })
]
};
const briefing = buildBriefing(scan, NOW);
expect(briefing.headline).toMatch(/at least/);
expect(briefing.detail).toMatch(/floor/i);
});
it('survives a scan with no rows at all', () => {
expect(buildBriefing({ rows: [] }, NOW).findings).toEqual([]);
expect(buildBriefing(undefined, NOW).findings).toEqual([]);
});
// The bug this pins: buildBriefing used to default `now` to
// new Date().toISOString(), which is UTC. parseDoormileTimestamp strips the
// zone and reads the digits as IST wall-clock, so the rules received a clock
// running 5h30m slow and nothing under five and a half hours overdue was ever
// flagged. It failed silently — an all-clear board through most of a shift.
//
// Deliberately exercised with NO explicit `now`, because passing one is what
// hid the bug from every other test in this file.
it('uses IST wall-clock for "now", not a UTC ISO string', () => {
jest.useFakeTimers();
try {
jest.setSystemTime(new Date('2026-08-29T14:00:00'));
const scan = {
truncated: false,
rows: [
rawBooking({
status: 'Pickup_Scheduled',
// 90 minutes ago in local wall-clock. Under a UTC-shifted clock
// this reads as still in the future and yields no finding.
serviceoptions: [{ estimateddeliveryat: '2026-08-29 12:30:00' }]
})
]
};
const briefing = buildBriefing(scan);
expect(briefing.findings.map((f) => f.id)).toContain('sla_breached');
} finally {
jest.useRealTimers();
}
});
it('carries a metric the panel can render', () => {
const scan = { truncated: false, rows: [rawBooking({ createdat: '2026-08-29 11:00:00' })] };
const { metric } = buildBriefing(scan, NOW);
expect(metric.value).toBeGreaterThan(0);
expect(metric.label).toEqual(expect.any(String));
});
});

View File

@@ -0,0 +1,172 @@
// The wiring nothing else covers.
//
// The executor is tested in isolation, and SlaRemediationCard is tested in
// isolation. What was never tested is the seam between them: does the banner
// actually hand the card an executor, does a click reach the real
// executeProposal, does the API get called, and does the board re-evaluate
// afterwards.
//
// That seam is where this feature would fail silently — a card rendered with no
// onRemediate looks identical to one wired correctly until somebody clicks it.
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import AgentOperationsBanner from '@/components/doormile/AgentOperationsBanner';
import * as scanModule from '@/lib/assistant/scan';
jest.mock('@/lib/assistant/scan');
jest.mock('lucide-react', () =>
new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />) })
);
// Only the network is faked. AgentFactory, SkillRegistry, the skills, the
// adapter, briefing and the executor are all the real implementations.
jest.mock('@/api/doormile', () => {
const actual = jest.requireActual('@/api/doormile/queries');
return {
__esModule: true,
getMilers: jest.fn(),
notifyRider: jest.fn(),
batchAssignBookings: jest.fn(),
buildMilerLookup: actual.buildMilerLookup,
mapBookingStatusToDeliveryStatus: actual.mapBookingStatusToDeliveryStatus
};
});
jest.mock('@/api/doormile/notify', () => ({ OpenToast: jest.fn() }));
const api = require('@/api/doormile');
const { OpenToast } = require('@/api/doormile/notify');
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
// A rider stamped arrival well over the 20-minute stall threshold.
const stalledScan = () => ({
truncated: false,
rows: [
{
bookingid: 2,
bookingno: 'DM-BK-002',
status: 'Pickup_Scheduled',
createdat: '2026-09-01 10:00:00',
reachedat: '2026-09-01 13:00:00',
assignedmileruserid: 8,
milername: 'Kumar',
serviceoptions: []
}
]
});
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers({ doNotFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'queueMicrotask'] });
jest.setSystemTime(new Date('2026-09-01T14:00:00'));
scanModule.scanBookings.mockResolvedValue(stalledScan());
api.getMilers.mockResolvedValue([{ userid: 8, milerprofileid: 501, displayname: 'Kumar' }]);
api.notifyRider.mockResolvedValue({ success: true });
});
afterEach(() => {
jest.useRealTimers();
});
const renderBanner = () =>
render(
<QueryClientProvider client={client()}>
<MemoryRouter>
<AgentOperationsBanner />
</MemoryRouter>
</QueryClientProvider>
);
describe('click-through: banner to executor to API', () => {
it('surfaces a real stall finding from raw bookings', async () => {
renderBanner();
expect(await screen.findByText(/stalled at door/i)).toBeInTheDocument();
});
// The whole point. A card with no executor renders disabled and says
// "Review only"; if the banner failed to pass onRemediate this would fail.
it('renders the stall proposal as clickable, not review-only', async () => {
renderBanner();
await screen.findByText(/stalled at door/i);
expect(screen.queryByRole('button', { name: /review only/i })).not.toBeInTheDocument();
expect(await screen.findByRole('button', { name: /message waiting riders/i })).toBeEnabled();
});
it('a click reaches the real executor and pushes to the right rider', async () => {
renderBanner();
await screen.findByText(/stalled at door/i);
fireEvent.click(await screen.findByRole('button', { name: /message waiting riders/i }));
await waitFor(() => {
expect(api.notifyRider).toHaveBeenCalledTimes(1);
});
// Through the REAL buildMilerLookup: mileruserid 8 becomes profile 501.
expect(api.notifyRider.mock.calls[0][0]).toBe(501);
expect(api.notifyRider.mock.calls[0][0]).not.toBe(8);
});
it('re-scans after acting, which is what makes it a loop', async () => {
renderBanner();
await screen.findByText(/stalled at door/i);
const scansBefore = scanModule.scanBookings.mock.calls.length;
fireEvent.click(await screen.findByRole('button', { name: /message waiting riders/i }));
// The verify pass: the board is re-evaluated rather than assumed fixed.
await waitFor(() => {
expect(scanModule.scanBookings.mock.calls.length).toBeGreaterThan(scansBefore);
});
});
it('reports success only after the push actually landed', async () => {
renderBanner();
await screen.findByText(/stalled at door/i);
fireEvent.click(await screen.findByRole('button', { name: /message waiting riders/i }));
await waitFor(() => {
expect(OpenToast).toHaveBeenCalledWith(expect.stringMatching(/Notified 1 rider/), 'success');
});
});
// The failure that matters: the push is refused, and the operator must not be
// told a rider was messaged.
it('reports failure, not success, when the push is refused', async () => {
api.notifyRider.mockRejectedValue(new Error('device offline'));
renderBanner();
await screen.findByText(/stalled at door/i);
fireEvent.click(await screen.findByRole('button', { name: /message waiting riders/i }));
await waitFor(() => {
expect(OpenToast).toHaveBeenCalledWith(expect.any(String), 'error');
});
expect(OpenToast).not.toHaveBeenCalledWith(expect.anything(), 'success');
});
// A rider who has left the fleet cannot be pushed to. Nothing landed, so
// nothing may be reported as landed.
it('does not claim success when no rider could be reached', async () => {
api.getMilers.mockResolvedValue([]); // rider 8 is gone
renderBanner();
await screen.findByText(/stalled at door/i);
fireEvent.click(await screen.findByRole('button', { name: /message waiting riders/i }));
await waitFor(() => {
expect(OpenToast).toHaveBeenCalledWith(expect.any(String), 'error');
});
expect(api.notifyRider).not.toHaveBeenCalled();
expect(OpenToast).not.toHaveBeenCalledWith(expect.anything(), 'success');
});
});

View File

@@ -0,0 +1,167 @@
// Does asking for a status sweep actually reach the agent?
//
// The regex was verified in isolation, which proves nothing about routing:
// answerQuestion walks 26 intents in order and returns the FIRST whose match()
// recognises the text and whose run() resolves. Several later intents match on
// bare substrings ("orders", "riders", "status") that appear in almost every
// phrasing of "what needs attention", so ordering is the whole ballgame — and
// ordering is exactly what a unit test of the regex cannot see.
jest.mock('pages/api/doormileApi', () => ({
__esModule: true,
getBookingsPage: jest.fn(),
getHubs: jest.fn(async () => []),
getVehicles: jest.fn(async () => []),
getTripsheets: jest.fn(async () => []),
getExceptions: jest.fn(async () => []),
getAppUsers: jest.fn(async () => []),
getAdminCustomers: jest.fn(async () => []),
getAdminPricing: jest.fn(async () => []),
getMilers: jest.fn(async () => []),
getConsignments: jest.fn(async () => []),
getPartners: jest.fn(async () => []),
getCompetitorBranches: jest.fn(async () => []),
getCarrierPricing: jest.fn(async () => []),
getMilerSummary: jest.fn(async () => []),
getMilerActivity: jest.fn(async () => []),
trackConsignment: jest.fn(async () => null),
getConsignmentLogs: jest.fn(async () => []),
getAdminTenant: jest.fn(async () => null),
getTenantLocations: jest.fn(async () => [])
}));
jest.mock('pages/api/api', () => ({
__esModule: true,
getalltenants: jest.fn(async () => []),
getallridersummary: jest.fn(async () => [])
}));
let getBookingsPage;
// intents.js keeps a module-local page cache with a 20s TTL, so a second test
// would otherwise be answered from the first test's fixture. Re-requiring the
// module per test gives each one a clean cache.
//
// Time is pinned too: the agent's rules are all relative to `now`, and the
// intent reads the real clock. Without a fixed instant these assertions would
// pass or fail depending on the hour the suite happened to run.
const NOW = new Date('2026-08-29T14:00:00');
let answerQuestion;
// One page of raw bookings holding a breach, a stalled rider and aging work.
const page = () => ({
total: 3,
rows: [
{
bookingid: 1,
bookingno: 'DM-BK-001',
status: 'Pickup_Scheduled',
createdat: '2026-08-29 09:00:00',
assignedmileruserid: 7,
milername: 'Suresh',
serviceoptions: [{ estimateddeliveryat: '2026-08-29 12:30:00' }]
},
{
bookingid: 2,
bookingno: 'DM-BK-002',
status: 'Pickup_Scheduled',
createdat: '2026-08-29 10:00:00',
reachedat: '2026-08-29 13:15:00',
assignedmileruserid: 8,
milername: 'Kumar',
serviceoptions: []
},
{ bookingid: 3, bookingno: 'DM-BK-003', status: 'Pending_Pickup', createdat: '2026-08-29 11:30:00', serviceoptions: [] }
]
});
beforeEach(() => {
jest.useFakeTimers({ doNotFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] });
jest.setSystemTime(NOW);
jest.resetModules();
// resetModules rebuilds intents.js AND its dependencies, so the mock has to
// be re-required and re-configured here. Configuring the previous instance
// would leave the copy intents.js actually holds returning undefined.
({ getBookingsPage } = require('pages/api/doormileApi'));
getBookingsPage.mockResolvedValue(page());
({ answerQuestion } = require('@/lib/assistant/intents'));
});
afterEach(() => {
jest.useRealTimers();
});
describe('routing to the ops agent', () => {
const sweepPhrasings = [
'what needs attention',
'anything going wrong',
'what needs my attention right now',
'ops check',
'daily briefing',
'what should i do'
];
it.each(sweepPhrasings)('"%s" reaches the agent', async (question) => {
const result = await answerQuestion(question);
expect(result).toBeTruthy();
expect(result.intentId).toBe('opsBriefing');
});
// The other side of the contract. These must keep going to the intents that
// answer them properly — over-claiming here would silently change answers
// across the whole catalog.
const mustNotClaim = [
'how many orders today',
'how many riders are active',
'total revenue today',
'how many tenants',
// Narrowed on port: the branch claimed these, which would have replaced the
// delayed-orders count (Orders taxonomy) and the operations summary chip
// with a briefing. They keep going to the intents that answer them.
'late orders',
'which orders are delayed?',
"give me today's operations summary"
];
it.each(mustNotClaim)('"%s" is NOT swallowed by the agent', async (question) => {
const result = await answerQuestion(question);
expect(result?.intentId).not.toBe('opsBriefing');
});
});
describe('the agent answer itself', () => {
it('reports the real findings from the scanned rows', async () => {
const result = await answerQuestion('what needs attention');
expect(result.headline).toMatch(/need.*attention now/i);
// Assert on the evidence, not on a skill's prose. The wording belongs to
// the skill definition and an ops lead may reasonably reword it; the
// booking reference and the overdue figure are the substance.
expect(result.detail).toMatch(/DM-BK-001/);
expect(result.detail).toMatch(/90 min overdue/);
});
// The verifiability contract: every answer shows which endpoint produced it.
it('carries a source call naming the endpoint it queried', async () => {
const result = await answerQuestion('what needs attention');
expect(Array.isArray(result.sourceCalls)).toBe(true);
expect(result.sourceCalls.length).toBeGreaterThan(0);
expect(result.sourceCalls[0].target).toMatch(/\/admin\/bookings/);
});
it('carries a metric the panel can render', async () => {
const result = await answerQuestion('what needs attention');
expect(result.metric.value).toBeGreaterThan(0);
});
it('says the board is clear when it is', async () => {
getBookingsPage.mockResolvedValue({ total: 1, rows: [
{ bookingid: 9, bookingno: 'DM-BK-009', status: 'Delivered', createdat: '2026-08-29 13:59:00', serviceoptions: [] }
] });
const result = await answerQuestion('what needs attention');
expect(result.intentId).toBe('opsBriefing');
expect(result.headline).toMatch(/Nothing needs attention/i);
});
});

View File

@@ -0,0 +1,164 @@
// 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');
});
});

View File

@@ -1,4 +1,11 @@
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
import {
calculateDrivingDistance,
calculateDrivingRoute,
calculateTotalCharge,
formatRouteDistance,
formatRouteDuration,
getLastRouteDurationMin
} from '@/lib/distance';
/**
* Distance and charge for a delivery leg.
@@ -183,6 +190,60 @@ describe('calculateDrivingDistance', () => {
});
});
describe('calculateDrivingRoute — sub-kilometre legs', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
it('should keep the unrounded road length in metres alongside whole-km distance', async () => {
// A same-address order: OSRM snaps both pins to the road and returns ~120 m.
// `distance` (billed) rounds to 0 km; `meters` is what the operator sees.
global.fetch.mockResolvedValue(osrmOk(123.4, 20));
const res = await calculateDrivingRoute(BLR, { latitude: 12.9717, longitude: 77.5947 });
expect(res.distance).toBe(0);
expect(res.meters).toBe(123);
expect(res.minutes).toBe(0);
});
it('should report metres on the Haversine fallback too', async () => {
global.fetch.mockResolvedValue({ ok: false, json: async () => ({}) });
const res = await calculateDrivingRoute(BLR, { ...BLR });
expect(res.distance).toBe(0);
expect(res.meters).toBe(0);
});
});
describe('formatRouteDistance', () => {
it.each([
[0, '0 m'],
[123, '120 m'],
[996, '1 km'],
[1000, '1 km'],
[1449, '1.4 km'],
[8200, '8.2 km']
])('should format %d m as %s', (meters, expected) => {
expect(formatRouteDistance(meters)).toBe(expected);
});
it.each([[null], [undefined], [NaN], [-5]])('should show a dash for %p', (value) => {
expect(formatRouteDistance(value)).toBe('—');
});
});
describe('formatRouteDuration', () => {
it('should show "< 1 min" for a sub-minute drive rather than a default', () => {
expect(formatRouteDuration(0)).toBe('< 1 min');
});
it('should show whole minutes', () => {
expect(formatRouteDuration(15)).toBe('15 mins');
});
it.each([[null], [undefined]])('should show a dash when there is no ETA (%p)', (value) => {
expect(formatRouteDuration(value)).toBe('—');
});
});
describe('calculateTotalCharge', () => {
it('should charge only the base price below the minimum distance', () => {
expect(calculateTotalCharge(3, 30, 6, 8)).toBe(30);

View File

@@ -0,0 +1,67 @@
import { CashExposureSkill } from '@/lib/assistant/skills/definitions/CashExposureSkill';
describe('CashExposureSkill', () => {
const NOW = '2026-08-31 14:00:00';
it('flags riders carrying total COD cash above max threshold', () => {
const rows = [
// Rider 1: 12000 total (Critical)
{ bookingid: 1, userid: 101, ridername: 'Rider A', orderstatus: 'picked', paymenttype: 'cod', cod_amount: '5000' },
{ bookingid: 2, userid: 101, ridername: 'Rider A', orderstatus: 'accepted', paymenttype: 'cod', cod_amount: '7000' },
// Rider 2: 8000 total (Warning)
{ bookingid: 3, userid: 102, ridername: 'Rider B', orderstatus: 'picked', paymenttype: 'cod', cod_amount: '8000' },
// Rider 3: 4000 total (Safe)
{ bookingid: 4, userid: 103, ridername: 'Rider C', orderstatus: 'picked', paymenttype: 'cod', cod_amount: '4000' },
// Non-COD order for Rider 1 (Ignored)
{ bookingid: 5, userid: 101, ridername: 'Rider A', orderstatus: 'picked', paymenttype: 'prepaid', cod_amount: '0' }
];
const findings = CashExposureSkill.evaluate(rows, NOW, { maxCashPerRider: 10000, warningCashPercent: 75 });
const critical = findings.find((f) => f.id === 'cash_exposure_critical');
const warning = findings.find((f) => f.id === 'cash_exposure_warning');
expect(critical).toBeDefined();
expect(critical.count).toBe(1); // Only Rider 1
expect(critical.rows.length).toBe(2); // Rider 1 has 2 COD orders
expect(critical.detail[0]).toContain('12,000');
expect(warning).toBeDefined();
expect(warning.count).toBe(1); // Only Rider 2
expect(warning.detail[0]).toContain('8,000');
});
it('returns no findings when all riders are under warning threshold', () => {
const rows = [
{ bookingid: 1, userid: 101, orderstatus: 'picked', paymenttype: 'cod', cod_amount: '5000' }
];
const findings = CashExposureSkill.evaluate(rows, NOW, { maxCashPerRider: 10000, warningCashPercent: 75 });
expect(findings.length).toBe(0);
});
it('executes enforce_cash_handoff tool handler', async () => {
const tool = CashExposureSkill.tools.find((t) => t.name === 'enforce_cash_handoff');
expect(tool).toBeDefined();
const result = await tool.handler({ milerId: 101, totalCash: 12000, hubId: 'HUB-1' });
expect(result.success).toBe(true);
expect(result.proposal.action).toBe('cash_handoff');
expect(result.proposal.milerId).toBe(101);
expect(result.proposal.totalCash).toBe(12000);
expect(result.proposal.hubId).toBe('HUB-1');
});
it('handles orders with non-numeric or missing amounts gracefully', () => {
const rows = [
{ bookingid: 1, userid: 101, orderstatus: 'picked', paymenttype: 'cod' }, // missing amount
{ bookingid: 2, userid: 101, orderstatus: 'picked', paymenttype: 'cod', cod_amount: 'invalid' }, // invalid amount
{ bookingid: 3, userid: 101, orderstatus: 'picked', paymenttype: 'cod', cod_amount: '12000' } // valid amount
];
const findings = CashExposureSkill.evaluate(rows, NOW, { maxCashPerRider: 10000, warningCashPercent: 75 });
const critical = findings.find((f) => f.id === 'cash_exposure_critical');
expect(critical).toBeDefined();
expect(critical.detail[0]).toContain('12,000');
});
});

View File

@@ -0,0 +1,42 @@
import { DoorstepStallSkill } from '@/lib/assistant/skills/definitions/DoorstepStallSkill';
describe('DoorstepStallSkill', () => {
const fixedNow = '2026-08-31 12:00:00';
it('detects riders stalled at door past threshold', () => {
const rows = [
{
bookingid: 1,
bookingno: 'BK-1',
orderstatus: 'arrived',
reachedat: '2026-08-31 11:35:00', // 25 min waiting
ridername: 'Karthik Raja',
userid: 44,
},
{
bookingid: 2,
bookingno: 'BK-2',
orderstatus: 'arrived',
reachedat: '2026-08-31 11:55:00', // 5 min waiting
ridername: 'Praveen',
userid: 45,
},
];
const findings = DoorstepStallSkill.evaluate(rows, fixedNow, { arrivedStalledMin: 20 });
expect(findings.length).toBe(1);
expect(findings[0].id).toBe('stalled_at_door');
expect(findings[0].count).toBe(1);
expect(findings[0].rows[0].bookingid).toBe(1);
});
it('executes ping_stalled_rider tool handler', async () => {
const tool = DoorstepStallSkill.tools.find((t) => t.name === 'ping_stalled_rider');
expect(tool).toBeDefined();
const result = await tool.handler({ milerId: 44 });
expect(result.success).toBe(true);
expect(result.proposal.action).toBe('notify_miler');
expect(result.proposal.milerId).toBe(44);
});
});

View File

@@ -0,0 +1,39 @@
import { FleetBalancerSkill } from '@/lib/assistant/skills/definitions/FleetBalancerSkill';
describe('FleetBalancerSkill', () => {
it('detects saturated riders when pending orders are waiting in queue', () => {
const rows = [
// 3 active jobs for rider 10
{ bookingid: 1, userid: 10, ridername: 'Rider A', orderstatus: 'picked' },
{ bookingid: 2, userid: 10, ridername: 'Rider A', orderstatus: 'active' },
{ bookingid: 3, userid: 10, ridername: 'Rider A', orderstatus: 'accepted' },
// 1 pending order in queue
{ bookingid: 4, userid: null, orderstatus: 'pending' },
];
const findings = FleetBalancerSkill.evaluate(rows, '2026-08-31 12:00:00', { riderActiveCap: 3 });
expect(findings.length).toBe(1);
expect(findings[0].id).toBe('rider_saturation');
expect(findings[0].count).toBe(1);
});
it('does not flag saturation if no unassigned orders are waiting in queue', () => {
const rows = [
{ bookingid: 1, userid: 10, ridername: 'Rider A', orderstatus: 'picked' },
{ bookingid: 2, userid: 10, ridername: 'Rider A', orderstatus: 'active' },
{ bookingid: 3, userid: 10, ridername: 'Rider A', orderstatus: 'accepted' },
];
const findings = FleetBalancerSkill.evaluate(rows, '2026-08-31 12:00:00', { riderActiveCap: 3 });
expect(findings.length).toBe(0);
});
it('executes balance_rider_load tool handler', async () => {
const tool = FleetBalancerSkill.tools.find((t) => t.name === 'balance_rider_load');
expect(tool).toBeDefined();
const result = await tool.handler({ fromMilerId: 10, toMilerId: 20 });
expect(result.success).toBe(true);
expect(result.proposal.action).toBe('rebalance_workload');
});
});

View File

@@ -0,0 +1,37 @@
import { HighValueCodSkill } from '@/lib/assistant/skills/definitions/HighValueCodSkill';
describe('HighValueCodSkill', () => {
it('detects high value COD consignments at or above threshold', () => {
const rows = [
{
bookingid: 1,
bookingno: 'BK-COD-1',
orderstatus: 'active',
paymenttype: 'COD',
orderamount: 4500,
},
{
bookingid: 2,
bookingno: 'BK-COD-2',
orderstatus: 'active',
paymenttype: 'COD',
orderamount: 1500,
},
];
const findings = HighValueCodSkill.evaluate(rows, '2026-08-31 12:00:00', { codRiskThresholdAmount: 3000 });
expect(findings.length).toBe(1);
expect(findings[0].id).toBe('high_value_cod_alert');
expect(findings[0].count).toBe(1);
expect(findings[0].rows[0].bookingid).toBe(1);
});
it('executes enforce_otp_verification tool handler', async () => {
const tool = HighValueCodSkill.tools.find((t) => t.name === 'enforce_otp_verification');
expect(tool).toBeDefined();
const result = await tool.handler({ bookingId: 1 });
expect(result.success).toBe(true);
expect(result.proposal.action).toBe('require_otp');
});
});

View File

@@ -0,0 +1,50 @@
import { HubCongestionSkill } from '@/lib/assistant/skills/definitions/HubCongestionSkill';
describe('HubCongestionSkill', () => {
const NOW = '2026-08-31 14:00:00';
it('detects parcels stalled at hub beyond dwell threshold', () => {
const rows = [
{ bookingid: 1, bookingno: 'BK-1', orderstatus: 'pending', orderdate: '2026-08-31 13:00:00' }, // 60 min
{ bookingid: 2, bookingno: 'BK-2', orderstatus: 'accepted', orderdate: '2026-08-31 13:10:00' }, // 50 min
{ bookingid: 3, bookingno: 'BK-3', orderstatus: 'pending', orderdate: '2026-08-31 13:50:00' }, // 10 min — not stalled
{ bookingid: 4, bookingno: 'BK-4', orderstatus: 'picked', orderdate: '2026-08-31 12:00:00' }, // in transit — excluded
];
const findings = HubCongestionSkill.evaluate(rows, NOW, { hubDwellMinutes: 45 });
expect(findings.length).toBe(1);
expect(findings[0].id).toBe('hub_parcel_stalled');
expect(findings[0].count).toBe(2);
expect(findings[0].rows.map((r) => r.bookingid)).toContain(1);
expect(findings[0].rows.map((r) => r.bookingid)).toContain(2);
expect(findings[0].rows.map((r) => r.bookingid)).not.toContain(3);
});
it('returns no findings when all parcels are within dwell threshold', () => {
const rows = [
{ bookingid: 5, orderstatus: 'pending', orderdate: '2026-08-31 13:50:00' }, // 10 min
];
const findings = HubCongestionSkill.evaluate(rows, NOW, { hubDwellMinutes: 45 });
expect(findings.length).toBe(0);
});
it('executes dispatch_hub_idle_parcels tool handler', async () => {
const tool = HubCongestionSkill.tools.find((t) => t.name === 'dispatch_hub_idle_parcels');
expect(tool).toBeDefined();
const result = await tool.handler({ bookingIds: [1, 2], milerId: 99 });
expect(result.success).toBe(true);
expect(result.proposal.action).toBe('dispatch_hub_parcels');
expect(result.proposal.bookingIds).toEqual([1, 2]);
});
it('respects custom dwell threshold', () => {
const rows = [
{ bookingid: 10, orderstatus: 'pending', orderdate: '2026-08-31 13:45:00' }, // 15 min
];
const findingsStrict = HubCongestionSkill.evaluate(rows, NOW, { hubDwellMinutes: 10 });
expect(findingsStrict.length).toBe(1);
const findingsLenient = HubCongestionSkill.evaluate(rows, NOW, { hubDwellMinutes: 30 });
expect(findingsLenient.length).toBe(0);
});
});

View File

@@ -0,0 +1,53 @@
import { LateDispatchSkill } from '@/lib/assistant/skills/definitions/LateDispatchSkill';
describe('LateDispatchSkill', () => {
const NOW = '2026-08-31 14:00:00';
it('detects critically late dispatch orders', () => {
const rows = [
{ bookingid: 1, bookingno: 'BK-1', orderstatus: 'pending', orderdate: '2026-08-31 11:30:00' }, // 150 min
{ bookingid: 2, bookingno: 'BK-2', orderstatus: 'pending', orderdate: '2026-08-31 12:30:00' }, // 90 min
{ bookingid: 3, bookingno: 'BK-3', orderstatus: 'pending', orderdate: '2026-08-31 13:20:00' }, // 40 min — warning only
{ bookingid: 4, bookingno: 'BK-4', orderstatus: 'pending', orderdate: '2026-08-31 13:50:00' }, // 10 min — no issue
{ bookingid: 5, bookingno: 'BK-5', orderstatus: 'picked', orderdate: '2026-08-31 11:00:00' }, // dispatched — excluded
];
const findings = LateDispatchSkill.evaluate(rows, NOW, { lateDispatchMinutes: 30, criticalDispatchMinutes: 60 });
const critical = findings.find((f) => f.id === 'late_dispatch_critical');
const warning = findings.find((f) => f.id === 'late_dispatch_warning');
expect(critical).toBeDefined();
expect(critical.count).toBe(2); // BK-1 and BK-2
expect(warning).toBeDefined();
expect(warning.count).toBe(1); // BK-3
});
it('returns no findings when all orders are dispatched quickly', () => {
const rows = [
{ bookingid: 1, orderstatus: 'pending', orderdate: '2026-08-31 13:55:00' }, // 5 min
];
const findings = LateDispatchSkill.evaluate(rows, NOW, { lateDispatchMinutes: 30, criticalDispatchMinutes: 60 });
expect(findings.length).toBe(0);
});
it('executes trigger_auto_dispatch tool handler', async () => {
const tool = LateDispatchSkill.tools.find((t) => t.name === 'trigger_auto_dispatch');
expect(tool).toBeDefined();
const result = await tool.handler({ bookingIds: [1, 2], reason: 'Test late dispatch' });
expect(result.success).toBe(true);
expect(result.proposal.action).toBe('auto_dispatch');
expect(result.proposal.bookingIds).toEqual([1, 2]);
});
it('excludes non-pending status orders', () => {
const rows = [
{ bookingid: 1, orderstatus: 'accepted', orderdate: '2026-08-31 11:00:00' },
{ bookingid: 2, orderstatus: 'picked', orderdate: '2026-08-31 11:00:00' },
{ bookingid: 3, orderstatus: 'delivered', orderdate: '2026-08-31 11:00:00' },
];
const findings = LateDispatchSkill.evaluate(rows, NOW, { lateDispatchMinutes: 30, criticalDispatchMinutes: 60 });
expect(findings.length).toBe(0);
});
});

View File

@@ -0,0 +1,39 @@
import { RiderBatterySafetySkill } from '@/lib/assistant/skills/definitions/RiderBatterySafetySkill';
describe('RiderBatterySafetySkill', () => {
it('detects active deliveries where rider device battery is critical', () => {
const rows = [
{
bookingid: 1,
bookingno: 'BK-1',
orderstatus: 'picked',
userid: 50,
ridername: 'Karthik',
battery_percentage: 12,
},
{
bookingid: 2,
bookingno: 'BK-2',
orderstatus: 'picked',
userid: 51,
ridername: 'Praveen',
battery_percentage: 85,
},
];
const findings = RiderBatterySafetySkill.evaluate(rows, '2026-08-31 12:00:00', { criticalBatteryPercent: 15 });
expect(findings.length).toBe(1);
expect(findings[0].id).toBe('rider_critical_battery');
expect(findings[0].count).toBe(1);
expect(findings[0].rows[0].bookingid).toBe(1);
});
it('executes alert_low_battery_rider tool handler', async () => {
const tool = RiderBatterySafetySkill.tools.find((t) => t.name === 'alert_low_battery_rider');
expect(tool).toBeDefined();
const result = await tool.handler({ milerId: 50 });
expect(result.success).toBe(true);
expect(result.proposal.action).toBe('battery_alert');
});
});

View File

@@ -0,0 +1,78 @@
import { SlaGuardianSkill } from '@/lib/assistant/skills/definitions/SlaGuardianSkill';
describe('SlaGuardianSkill', () => {
const fixedNow = '2026-08-31 12:00:00';
it('detects breached SLA orders', () => {
const rows = [
{
bookingid: 1,
bookingno: 'BK-1',
orderstatus: 'pending',
expecteddeliverytime: '2026-08-31 11:30:00', // 30 min overdue
},
{
bookingid: 2,
bookingno: 'BK-2',
orderstatus: 'delivered', // terminal, should be ignored
expecteddeliverytime: '2026-08-31 11:30:00',
},
];
const findings = SlaGuardianSkill.evaluate(rows, fixedNow);
const breach = findings.find((f) => f.id === 'sla_breached');
expect(breach).toBeDefined();
expect(breach.count).toBe(1);
expect(breach.rows[0].bookingid).toBe(1);
});
it('detects at-risk SLA orders within custom threshold window', () => {
const rows = [
{
bookingid: 10,
bookingno: 'BK-10',
orderstatus: 'accepted',
expecteddeliverytime: '2026-08-31 12:20:00', // due in 20 min
},
];
// Default window is 45 min -> should match
const findings = SlaGuardianSkill.evaluate(rows, fixedNow, { slaRiskWindowMin: 45 });
expect(findings.some((f) => f.id === 'sla_at_risk')).toBe(true);
// If threshold overridden to 15 min -> should not match 20 min
const restricted = SlaGuardianSkill.evaluate(rows, fixedNow, { slaRiskWindowMin: 15 });
expect(restricted.some((f) => f.id === 'sla_at_risk')).toBe(false);
});
it('detects unassigned aging orders', () => {
const rows = [
{
bookingid: 20,
bookingno: 'BK-20',
orderstatus: 'pending',
orderdate: '2026-08-31 10:30:00', // 90 min old
},
];
const findings = SlaGuardianSkill.evaluate(rows, fixedNow, { unassignedAgingMin: 60 });
expect(findings.some((f) => f.id === 'unassigned_aging')).toBe(true);
});
it('executes reassign_sla_critical_order tool handler', async () => {
const tool = SlaGuardianSkill.tools.find((t) => t.name === 'reassign_sla_critical_order');
expect(tool).toBeDefined();
const result = await tool.handler({
bookingId: 101,
targetMilerId: 55,
reason: 'Urgent priority reassignment',
});
expect(result.success).toBe(true);
expect(result.proposal.action).toBe('reassign_miler');
expect(result.proposal.bookingId).toBe(101);
expect(result.proposal.milerId).toBe(55);
});
});

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { render } from '@testing-library/react';
/**
* The one bridge from the backend agent registry into the in-memory
* SkillRegistry the banner and the chat briefing both read.
*/
let mockQuery = { data: undefined, isError: false };
const mockUseAiSkills = jest.fn(() => mockQuery);
jest.mock('@/lib/doormileHooks', () => ({ useAiSkills: (...args) => mockUseAiSkills(...args) }));
import { useSkillRegistrySync } from '@/lib/assistant/skills/useSkillRegistrySync';
import { SkillRegistry } from '@/lib/assistant/skills/SkillRegistry';
function Probe({ enabled }) {
useSkillRegistrySync(enabled);
return null;
}
beforeEach(() => {
SkillRegistry.clearRegistryConfig();
mockQuery = { data: undefined, isError: false };
mockUseAiSkills.mockClear();
});
describe('useSkillRegistrySync', () => {
it('applies the registry rows once they arrive', () => {
mockQuery = { data: [{ skillid: 'skill_doorstep_stall', enabled: true, thresholds: { arrivedStalledMin: 30 } }], isError: false };
render(<Probe enabled />);
expect(SkillRegistry.getSource()).toBe('registry');
expect(SkillRegistry.getSkill('skill_doorstep_stall').effectiveThresholds.arrivedStalledMin.currentValue).toBe(30);
});
it('falls back to code defaults when the registry cannot be read', () => {
SkillRegistry.applyRegistryConfig([{ skillid: 'skill_doorstep_stall', enabled: false, thresholds: {} }]);
mockQuery = { data: undefined, isError: true };
render(<Probe enabled />);
expect(SkillRegistry.getSource()).toBe('defaults');
expect(SkillRegistry.getSkill('skill_doorstep_stall').enabled).toBe(true);
});
it('does not query the registry for a partner login, and runs on defaults', () => {
render(<Probe enabled={false} />);
expect(mockUseAiSkills).toHaveBeenCalledWith({ enabled: false, retry: false });
expect(SkillRegistry.getSource()).toBe('defaults');
});
});