update on the agents and registry side
This commit is contained in:
724
docs/agent-platform-plan.md
Normal file
724
docs/agent-platform-plan.md
Normal 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.
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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?' }
|
||||
],
|
||||
|
||||
196
src/components/doormile/AgentOperationsBanner.jsx
Normal file
196
src/components/doormile/AgentOperationsBanner.jsx
Normal 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 & 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
137
src/components/doormile/SlaRemediationCard.jsx
Normal file
137
src/components/doormile/SlaRemediationCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)];
|
||||
|
||||
92
src/lib/assistant/agent/AgentFactory.js
Normal file
92
src/lib/assistant/agent/AgentFactory.js
Normal 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());
|
||||
}
|
||||
}
|
||||
153
src/lib/assistant/agent/actions.js
Normal file
153
src/lib/assistant/agent/actions.js
Normal 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);
|
||||
};
|
||||
112
src/lib/assistant/agent/briefing.js
Normal file
112
src/lib/assistant/agent/briefing.js
Normal 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' }
|
||||
};
|
||||
};
|
||||
53
src/lib/assistant/agent/normalise.js
Normal file
53
src/lib/assistant/agent/normalise.js
Normal 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) : []);
|
||||
44
src/lib/assistant/agent/signals.js
Normal file
44
src/lib/assistant/agent/signals.js
Normal 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.';
|
||||
@@ -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
|
||||
|
||||
123
src/lib/assistant/skills/SkillRegistry.js
Normal file
123
src/lib/assistant/skills/SkillRegistry.js
Normal 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();
|
||||
161
src/lib/assistant/skills/definitions/CashExposureSkill.js
Normal file
161
src/lib/assistant/skills/definitions/CashExposureSkill.js
Normal 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;
|
||||
}
|
||||
};
|
||||
90
src/lib/assistant/skills/definitions/DoorstepStallSkill.js
Normal file
90
src/lib/assistant/skills/definitions/DoorstepStallSkill.js
Normal 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.'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
87
src/lib/assistant/skills/definitions/FleetBalancerSkill.js
Normal file
87
src/lib/assistant/skills/definitions/FleetBalancerSkill.js
Normal 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`)
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
93
src/lib/assistant/skills/definitions/HighValueCodSkill.js
Normal file
93
src/lib/assistant/skills/definitions/HighValueCodSkill.js
Normal 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.'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
105
src/lib/assistant/skills/definitions/HubCongestionSkill.js
Normal file
105
src/lib/assistant/skills/definitions/HubCongestionSkill.js
Normal 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;
|
||||
}
|
||||
};
|
||||
133
src/lib/assistant/skills/definitions/LateDispatchSkill.js
Normal file
133
src/lib/assistant/skills/definitions/LateDispatchSkill.js
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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.'
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
157
src/lib/assistant/skills/definitions/SlaGuardianSkill.js
Normal file
157
src/lib/assistant/skills/definitions/SlaGuardianSkill.js
Normal 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;
|
||||
}
|
||||
};
|
||||
10
src/lib/assistant/skills/index.js
Normal file
10
src/lib/assistant/skills/index.js
Normal 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';
|
||||
27
src/lib/assistant/skills/useSkillRegistrySync.js
Normal file
27
src/lib/assistant/skills/useSkillRegistrySync.js
Normal 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]);
|
||||
}
|
||||
75
src/lib/clientOnboarding.js
Normal file
75
src/lib/clientOnboarding.js
Normal 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.`;
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
381
src/pages/doormile/clients/ClientOnboarding.jsx
Normal file
381
src/pages/doormile/clients/ClientOnboarding.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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's browser, so its runs are not recorded. The figures below are
|
||||
AI_engine'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>"{s.prompt}"</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">> 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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's skills — the Exceptions early warnings and the
|
||||
chat's “what needs attention” — run on these settings now. AI_engine's Dispatch,
|
||||
Exception, Express Dispatch and Customer agents pick up changes within about 30 seconds once its
|
||||
registry-reading build is deployed; until then, and whenever the registry cannot be reached, they run on
|
||||
their environment defaults. Hub, Fleet and Route Optimizer are simulations. The Test tab runs real 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
133
src/pages/doormile/settings/agentStudio/registryAdapters.js
Normal file
133
src/pages/doormile/settings/agentStudio/registryAdapters.js
Normal 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' },
|
||||
];
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
221
tests/integration/clientOnboarding.test.jsx
Normal file
221
tests/integration/clientOnboarding.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
108
tests/integration/createOrderRouteSummary.test.jsx
Normal file
108
tests/integration/createOrderRouteSummary.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
66
tests/lib/AgentFactory.test.js
Normal file
66
tests/lib/AgentFactory.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
134
tests/lib/AgentOperationsBanner.test.jsx
Normal file
134
tests/lib/AgentOperationsBanner.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
82
tests/lib/SkillRegistry.test.js
Normal file
82
tests/lib/SkillRegistry.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
125
tests/lib/SlaRemediationCard.test.jsx
Normal file
125
tests/lib/SlaRemediationCard.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
200
tests/lib/agentActions.test.js
Normal file
200
tests/lib/agentActions.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
141
tests/lib/agentBriefing.test.js
Normal file
141
tests/lib/agentBriefing.test.js
Normal 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));
|
||||
});
|
||||
});
|
||||
172
tests/lib/agentClickThrough.test.jsx
Normal file
172
tests/lib/agentClickThrough.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
167
tests/lib/agentRouting.test.js
Normal file
167
tests/lib/agentRouting.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
164
tests/lib/agentSingleEngine.test.js
Normal file
164
tests/lib/agentSingleEngine.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
67
tests/lib/skills/CashExposureSkill.test.js
Normal file
67
tests/lib/skills/CashExposureSkill.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
42
tests/lib/skills/DoorstepStallSkill.test.js
Normal file
42
tests/lib/skills/DoorstepStallSkill.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
39
tests/lib/skills/FleetBalancerSkill.test.js
Normal file
39
tests/lib/skills/FleetBalancerSkill.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
37
tests/lib/skills/HighValueCodSkill.test.js
Normal file
37
tests/lib/skills/HighValueCodSkill.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
50
tests/lib/skills/HubCongestionSkill.test.js
Normal file
50
tests/lib/skills/HubCongestionSkill.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
53
tests/lib/skills/LateDispatchSkill.test.js
Normal file
53
tests/lib/skills/LateDispatchSkill.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
39
tests/lib/skills/RiderBatterySafetySkill.test.js
Normal file
39
tests/lib/skills/RiderBatterySafetySkill.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
78
tests/lib/skills/SlaGuardianSkill.test.js
Normal file
78
tests/lib/skills/SlaGuardianSkill.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
48
tests/lib/useSkillRegistrySync.test.jsx
Normal file
48
tests/lib/useSkillRegistrySync.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user