@flowapt/flowiq-cli 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -3
- package/bin/flowiq.js +0 -0
- package/package.json +4 -3
- package/src/commands/agent-config.js +95 -0
- package/src/commands/agent-test.js +232 -0
- package/src/commands/agent-updates.js +186 -0
- package/src/commands/export.js +131 -0
- package/src/commands/flowmod.js +130 -0
- package/src/commands/groups.js +329 -0
- package/src/commands/org.js +33 -0
- package/src/commands/pinboard.js +174 -0
- package/src/commands/templates.js +156 -0
- package/src/data/scenarios.qa.json +41 -0
- package/src/index.js +171 -0
package/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# flowiq
|
|
2
2
|
|
|
3
3
|
FlowIQ staff CLI. Round-trips agent prompts, questionnaires, fine-tuning,
|
|
4
|
-
contact chats, platform webhooks,
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
contact chats, platform webhooks, outbound messaging webhooks, FlowMod
|
|
5
|
+
master-group prompts, Pin Board tasks, WhatsApp templates, agent-update
|
|
6
|
+
requests, full chat exports, and live agent testing through
|
|
7
|
+
`https://api.flowiq.live/cli/*` so the Supabase service role never leaves the
|
|
8
|
+
server.
|
|
7
9
|
|
|
8
10
|
## Install
|
|
9
11
|
|
|
@@ -150,12 +152,174 @@ Validation: `platform` ∈ `{whatsapp, web}`, `type` from the per-platform
|
|
|
150
152
|
allowed enum, valid http(s) URL, no duplicate (platform, type, url)
|
|
151
153
|
tuples.
|
|
152
154
|
|
|
155
|
+
### FlowMod prompts — `flowiq flowmod pull|push <slug>` (alias `fm`)
|
|
156
|
+
|
|
157
|
+
Round-trips a FlowMod org's **master-group** prompts + config. FlowMod groups
|
|
158
|
+
are a separate system from regular WhatsApp agents — their prompts live in the
|
|
159
|
+
`flowmod_master_groups` (model/pre-filter/config/signatures/custom_tools) and
|
|
160
|
+
`flowmod_prompts` (the `main` / `extra` prompt rows, keyed by `master_group_id`)
|
|
161
|
+
tables, read per-message by the `flowmod-group-*` webhooks.
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
flowiq flowmod pull <organization_id>
|
|
165
|
+
# edit ./.flowiq/flowmod/<slug>.json
|
|
166
|
+
flowiq fm push <slug>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The pulled JSON has a `master_groups[]` array; each entry carries its editable
|
|
170
|
+
fields plus a `prompts[]` array (`{ id, type, group_ids, text, data }`). Push
|
|
171
|
+
updates only editable columns — prompt `text`/`data`/`group_ids`/`type`, and
|
|
172
|
+
master-group `name`/`description`/`config`/`pre_filter_prompt`/`events_markdown`/
|
|
173
|
+
`custom_signature*`/`custom_tools` — scoped by row id; it never touches PKs,
|
|
174
|
+
FKs, or timestamps, and there's no `system_prompt` regeneration (FlowMod reads
|
|
175
|
+
the prompt rows directly).
|
|
176
|
+
|
|
177
|
+
Common uses: edit a master-group's moderator prompt, or flip its reasoning
|
|
178
|
+
effort via `config.reasoningEffort` (`none` / `low` / `medium` / `high`).
|
|
179
|
+
|
|
180
|
+
### Group chats — `flowiq groups list|pull <name-or-jid>` (alias `grp`)
|
|
181
|
+
|
|
182
|
+
Read-only pull of **WhatsApp group** chat history (e.g. the `… x Flowapt`
|
|
183
|
+
client comms groups) straight out of the FlowMod **Evolution** Postgres. This
|
|
184
|
+
is the analogue of `messages pull`, but for a group instead of a contact, so
|
|
185
|
+
you can pull a window and summarise it.
|
|
186
|
+
|
|
187
|
+
> ⚠️ Unlike every other command, this talks to a database **directly** (the
|
|
188
|
+
> Evolution DB is not Supabase and not the `fiq_staff_…` API). To keep this
|
|
189
|
+
> published package secret-free, the connection string comes from your shell
|
|
190
|
+
> environment — set `FLOWMOD_EVO_DB_URL` (or the five `FLOWMOD_DB_*` vars the
|
|
191
|
+
> `flowmod-copilot` edge fn uses) before running. No auth token is needed.
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
export FLOWMOD_EVO_DB_URL='postgres://user:pass@app.flowmod.ai:5555/flowiq-db'
|
|
195
|
+
|
|
196
|
+
flowiq groups list # default instance: flowapt
|
|
197
|
+
flowiq groups list --instance FlowMod # the other connected instance
|
|
198
|
+
|
|
199
|
+
flowiq groups pull "Phytoceutics x Flowapt" --last 7d
|
|
200
|
+
flowiq groups pull "Zorora x Flowapt" --since 2026-06-24 --until "2026-06-25 12:00"
|
|
201
|
+
flowiq grp pull 120363408455897717@g.us --last 48h # or pass the group JID
|
|
202
|
+
# writes ./.flowiq/groups/<instance>-<group-slug>.json + prints a timeline
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- Group is matched by JID, exact name, or a unique case-insensitive substring
|
|
206
|
+
(ambiguous matches list the candidates and exit — pass the JID to disambiguate).
|
|
207
|
+
- Window: `--last <48h|7d|2w|90m>` (default `48h`), or an explicit
|
|
208
|
+
`--since`/`--until` (SAST; `--until` defaults to now). `--limit` (default
|
|
209
|
+
1000) keeps the most-recent N in the window.
|
|
210
|
+
- `--json` prints the full payload to stdout instead of the timeline.
|
|
211
|
+
- Read-only — there is no `push`; group history is a system of record.
|
|
212
|
+
View-once/disappearing messages and raw media bodies can't be read (captions
|
|
213
|
+
and text come through); history before the instance joined a group may be gappy.
|
|
214
|
+
|
|
215
|
+
### Pin Board tasks — `flowiq pinboard pull|push|list|list-remote` (alias `pin`)
|
|
216
|
+
|
|
217
|
+
Round-trips a single internal Pin Board task (`flowapt_tasks` row).
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
flowiq pin list-remote open # find the task id you want
|
|
221
|
+
flowiq pin pull <task_id> # → ./.flowiq/pinboard/<slug>.json
|
|
222
|
+
flowiq pin push <slug> # writes editable fields back, stamps updated_at
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Editable fields: `name`, `description`, `status` (open / in_progress / done),
|
|
226
|
+
`urgency` (low / normal / high / critical), `preferred_due_date` (YYYY-MM-DD or
|
|
227
|
+
null), `organizations[]`, `media[]`, `messages[]`, `created_by`. Array columns
|
|
228
|
+
are full-replace within the row.
|
|
229
|
+
|
|
230
|
+
### WhatsApp templates — `flowiq templates pull|list|create|status` (alias `tpl`)
|
|
231
|
+
|
|
232
|
+
Read an org's live templates straight from Meta (read-only), and submit new
|
|
233
|
+
ones through the `create-meta-template` edge function.
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
flowiq templates pull <organization_id> # → ./.flowiq/templates/<slug>.json (Meta-side truth)
|
|
237
|
+
flowiq templates create <organization_id> --request-file req.json
|
|
238
|
+
flowiq templates status <organization_id> --name booking # poll approval
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`req.json` holds `{ template_request, template_data?, media_header?, cards_media? }`.
|
|
242
|
+
Media headers: pass `media_header.file_url` (a public URL) — the edge function
|
|
243
|
+
uploads it to Meta server-side. Approval is async; re-`pull` for the
|
|
244
|
+
authoritative Meta status.
|
|
245
|
+
|
|
246
|
+
### Org info — `flowiq org info <organization_id>`
|
|
247
|
+
|
|
248
|
+
Read-only platform detect + active-agent lookup for an org (raw store/Meta
|
|
249
|
+
credentials are stripped server-side).
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
flowiq org info <organization_id> # platform, storefront url, active agent
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Agent config — `flowiq agent config <organization_id> [flags]`
|
|
256
|
+
|
|
257
|
+
Set an allowlisted set of the active agent's config fields (the prompt-builder
|
|
258
|
+
"Step 5"). With no flags, prints the current config.
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
flowiq agent config <organization_id> # show current
|
|
262
|
+
flowiq agent config <organization_id> --use-settings-prompt --model gpt-5.4-mini \
|
|
263
|
+
--rename Zara --tool woo_order_build=true --tool view_cart_tool=true --discount true
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Settable: `settings.use_settings_prompt`, `settings.model`, agent `--rename`,
|
|
267
|
+
the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field`,
|
|
268
|
+
`view_cart_tool`, `restock_tool`, `block_tool_status`, `postal_code_tool_status`,
|
|
269
|
+
`shopify_products_web_chat`), and `discount.enabled`. Anything else is rejected;
|
|
270
|
+
every change is reported before → after.
|
|
271
|
+
|
|
272
|
+
### Agent updates — `flowiq agent-updates pull|list` (alias `au`)
|
|
273
|
+
|
|
274
|
+
Read-only pull of an org's client-raised "change the agent" requests
|
|
275
|
+
(`agent_updates`), each with the chat context around the triggering message +
|
|
276
|
+
the linked contact. Attachments are downloaded locally.
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
flowiq au pull <organization_id> # pending, with context
|
|
280
|
+
flowiq au pull <organization_id> --status all --titles "EFT, bank"
|
|
281
|
+
flowiq au pull <organization_id> --before 20 --after 10 --no-files
|
|
282
|
+
# → ./.flowiq/agent-updates/<slug>.json + files/<update-id>/<attachment>
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Chat export — `flowiq export chats <organization_id> [--out <path>]`
|
|
286
|
+
|
|
287
|
+
Full chat history → TXT, byte-identical to the in-app "Export Settings TXT"
|
|
288
|
+
(keyset-paginated server-side so it can't time out on big orgs).
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
flowiq export chats <organization_id> # → ./.flowiq/exports/<slug>-chats.txt
|
|
292
|
+
flowiq export chats <organization_id> --out ./phyto-chats.txt
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Agent testing — `flowiq test <subcommand>`
|
|
296
|
+
|
|
297
|
+
Drive an org's **live** agent through the web/test channel and read the reply
|
|
298
|
+
back — **never sends a real WhatsApp** (the web branch only inserts the reply).
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
flowiq test send <organization_id> "What are your bestsellers?" --clear
|
|
302
|
+
flowiq test send <organization_id> "What is this?" --image https://…/photo.jpg
|
|
303
|
+
flowiq test qa <organization_id> # bundled generic smoke pack
|
|
304
|
+
flowiq test scenario <organization_id> ./pack.json # your own pack (expect/expectNot)
|
|
305
|
+
flowiq test clear <organization_id>
|
|
306
|
+
# phase 2 — adversarial parallel stress:
|
|
307
|
+
flowiq test stress <organization_id> ./stress.json --concurrency 6
|
|
308
|
+
flowiq test extract <organization_id> # rebuild transcripts for a judge
|
|
309
|
+
flowiq test cleanup <organization_id> --confirm # delete the stress contacts
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
`--agent <id>` targets a specific (non-active) agent. `test stress cleanup` is
|
|
313
|
+
destructive and requires `--confirm`.
|
|
314
|
+
|
|
153
315
|
## Environment variable overrides
|
|
154
316
|
|
|
155
317
|
| Var | Default | Use |
|
|
156
318
|
|---|---|---|
|
|
157
319
|
| `FLOWIQ_API_URL` | `https://api.flowiq.live` | Override the API host (local dev, staging). |
|
|
158
320
|
| `FLOWIQ_TOKEN` | (saved in `~/.config/flowiq/auth.json`) | Override the auth token, useful for CI. |
|
|
321
|
+
| `FLOWMOD_EVO_DB_URL` | (none) | Full `postgres://` URL for `groups` (Evolution DB). Required for `groups`. |
|
|
322
|
+
| `FLOWMOD_DB_HOST/PORT/USER/PASS/NAME` | (none) | Alternative to `FLOWMOD_EVO_DB_URL` — assembled into a connection string. |
|
|
159
323
|
|
|
160
324
|
## Upgrading
|
|
161
325
|
|
package/bin/flowiq.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, webhooks, and
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"flowiq": "bin/flowiq.js"
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"start": "node bin/flowiq.js"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"commander": "^12.1.0"
|
|
21
|
+
"commander": "^12.1.0",
|
|
22
|
+
"pg": "^8.22.0"
|
|
22
23
|
},
|
|
23
24
|
"homepage": "https://github.com/MattFlowapt/flow-v1/tree/main/cli",
|
|
24
25
|
"repository": {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// `flowiq agent config <organization_id> [flags]` — the prompt-builder skill's
|
|
2
|
+
// Step 5 as a staff-key call. Writes an allowlisted set of agent fields
|
|
3
|
+
// (use_settings_prompt, model, tool flags, discount.enabled, rename) via
|
|
4
|
+
// /cli/agent-config. With no write flags, prints the current config.
|
|
5
|
+
|
|
6
|
+
import { http } from "../http.js";
|
|
7
|
+
|
|
8
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
|
|
10
|
+
function parseBool(v, label) {
|
|
11
|
+
if (v === true) return true; // bare presence of an optional flag
|
|
12
|
+
const s = String(v).toLowerCase();
|
|
13
|
+
if (s === "true" || s === "1" || s === "yes" || s === "on") return true;
|
|
14
|
+
if (s === "false" || s === "0" || s === "no" || s === "off") return false;
|
|
15
|
+
console.error(`Error: ${label} must be true or false (got "${v}").`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// commander collector for repeatable --tool woo_order_build=true
|
|
20
|
+
export function collectTool(val, acc) {
|
|
21
|
+
acc = acc || [];
|
|
22
|
+
acc.push(val);
|
|
23
|
+
return acc;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function printSnapshot(title, snap) {
|
|
27
|
+
console.log(` ${title}:`);
|
|
28
|
+
for (const [k, v] of Object.entries(snap)) {
|
|
29
|
+
console.log(` ${k.padEnd(30)} ${v === null ? "(unset)" : v}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function config(orgId, opts = {}) {
|
|
34
|
+
if (!UUID_RE.test(orgId)) {
|
|
35
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const body = { organization_id: orgId };
|
|
40
|
+
const settings = {};
|
|
41
|
+
if (opts.useSettingsPrompt !== undefined) settings.use_settings_prompt = parseBool(opts.useSettingsPrompt, "--use-settings-prompt");
|
|
42
|
+
if (opts.model !== undefined) settings.model = opts.model;
|
|
43
|
+
if (Object.keys(settings).length) body.settings = settings;
|
|
44
|
+
if (opts.rename !== undefined) body.name = opts.rename;
|
|
45
|
+
if (opts.discount !== undefined) body.discount_enabled = parseBool(opts.discount, "--discount");
|
|
46
|
+
|
|
47
|
+
if (opts.tool && opts.tool.length) {
|
|
48
|
+
const flags = {};
|
|
49
|
+
for (const pair of opts.tool) {
|
|
50
|
+
const eq = pair.indexOf("=");
|
|
51
|
+
if (eq < 0) {
|
|
52
|
+
console.error(`Error: --tool expects flag=bool (got "${pair}").`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
const key = pair.slice(0, eq).trim();
|
|
56
|
+
flags[key] = parseBool(pair.slice(eq + 1), `--tool ${key}`);
|
|
57
|
+
}
|
|
58
|
+
body.tool_flags = flags;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const hasWrite =
|
|
62
|
+
body.settings || body.name !== undefined || body.tool_flags || body.discount_enabled !== undefined;
|
|
63
|
+
|
|
64
|
+
// No write flags → just show current config.
|
|
65
|
+
if (!hasWrite) {
|
|
66
|
+
let resp;
|
|
67
|
+
try {
|
|
68
|
+
resp = await http.get("agent-config", { organization_id: orgId });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
console.error(`Lookup failed: ${e.message}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
console.log(`${resp.organization_name} → agent ${resp.agent_id}`);
|
|
74
|
+
printSnapshot("current", resp.current);
|
|
75
|
+
console.log("\n(pass --use-settings-prompt / --model / --rename / --tool / --discount to change)");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let resp;
|
|
80
|
+
try {
|
|
81
|
+
resp = await http.post("agent-config", body);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
console.error(`Config update failed: ${e.message}`);
|
|
84
|
+
if (e.body?.error) console.error(` ${e.body.error}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log(`Updated agent ${resp.agent_id} (${resp.organization_name})`);
|
|
89
|
+
console.log(` changed: ${resp.changed.join(", ") || "(nothing)"}`);
|
|
90
|
+
for (const k of resp.changed) {
|
|
91
|
+
const b = resp.before?.[k];
|
|
92
|
+
const a = resp.after?.[k];
|
|
93
|
+
console.log(` ${k.padEnd(30)} ${b === null || b === undefined ? "(unset)" : b} → ${a === null || a === undefined ? "(unset)" : a}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// `flowiq test <subcommand>` — drive an org's LIVE agent through the web/test
|
|
2
|
+
// channel (never sends a real WhatsApp). The server (/cli/agent-test,
|
|
3
|
+
// /cli/agent-stress) does one turn per request; this CLI loops turns, runs the
|
|
4
|
+
// expect/expectNot checks client-side, and prints the transcript.
|
|
5
|
+
//
|
|
6
|
+
// test resolve <org_id> [--agent <id>]
|
|
7
|
+
// test send <org_id> <message...> [--agent] [--clear] [--sender] [--image] [--timeout] [--json]
|
|
8
|
+
// test scenario <org_id> <file.json> [--agent] [--no-clear-between] [--json] [--sender] [--timeout]
|
|
9
|
+
// test qa <org_id> [--agent] ... (bundled generic smoke pack)
|
|
10
|
+
// test clear <org_id> [--agent]
|
|
11
|
+
// test stress <org_id> [pack.json] [--agent] [--concurrency 6] [--timeout] [--out] [--contact]
|
|
12
|
+
// test cleanup <org_id> [--agent] --confirm (deletes stress contacts)
|
|
13
|
+
// test extract <org_id> [--out] (rebuild transcripts from DB)
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs/promises";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { http } from "../http.js";
|
|
19
|
+
|
|
20
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const QA_PACK = path.resolve(__dirname, "..", "data", "scenarios.qa.json");
|
|
22
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
23
|
+
|
|
24
|
+
function requireOrg(orgId) {
|
|
25
|
+
if (!UUID_RE.test(orgId)) {
|
|
26
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function runTurn(orgId, { agent, message, image, clear, sender, timeout }) {
|
|
32
|
+
return http.post("agent-test", {
|
|
33
|
+
action: "send",
|
|
34
|
+
organization_id: orgId,
|
|
35
|
+
agent_id: agent,
|
|
36
|
+
message,
|
|
37
|
+
image_url: image,
|
|
38
|
+
clear: !!clear,
|
|
39
|
+
sender,
|
|
40
|
+
timeout_ms: timeout,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function clearConv(orgId, agent) {
|
|
45
|
+
return http.post("agent-test", { action: "clear", organization_id: orgId, agent_id: agent });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function checkExpectations(turn, replyText) {
|
|
49
|
+
const results = [];
|
|
50
|
+
const hay = (replyText || "").toLowerCase();
|
|
51
|
+
for (const e of turn.expect || []) results.push({ kind: "expect", needle: e, ok: hay.includes(String(e).toLowerCase()) });
|
|
52
|
+
for (const e of turn.expectNot || []) results.push({ kind: "expectNot", needle: e, ok: !hay.includes(String(e).toLowerCase()) });
|
|
53
|
+
return results;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function printTurn(sc, t, userText, r, checks) {
|
|
57
|
+
if (t === 0) console.log(`\n━━ ${sc.tq ? sc.tq + " · " : ""}${sc.title || sc.id} ━━`);
|
|
58
|
+
console.log(`\n 🧑 ${userText}`);
|
|
59
|
+
if (r.tool_calls?.length) console.log(` ⚙ tools: ${r.tool_calls.join(", ")}`);
|
|
60
|
+
if (r.reply) console.log(` 🤖 ${r.reply.split("\n").join("\n ")}`);
|
|
61
|
+
else console.log(` ⚠ no reply — status=${r.status}${r.raw?.error ? " (" + r.raw.error + ")" : ""}`);
|
|
62
|
+
for (const c of checks || []) {
|
|
63
|
+
console.log(` ${c.ok ? "✅" : "❌"} ${c.kind === "expectNot" ? "must NOT contain" : "contains"} "${c.needle}"`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function resolve(orgId, opts = {}) {
|
|
68
|
+
requireOrg(orgId);
|
|
69
|
+
let resp;
|
|
70
|
+
try { resp = await http.get("agent-test", { organization_id: orgId, agent_id: opts.agent }); }
|
|
71
|
+
catch (e) { console.error(`Resolve failed: ${e.message}`); process.exit(1); }
|
|
72
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function send(orgId, messageParts, opts = {}) {
|
|
76
|
+
requireOrg(orgId);
|
|
77
|
+
const message = (messageParts || []).join(" ");
|
|
78
|
+
if (!message) { console.error("usage: test send <org_id> <message...>"); process.exit(1); }
|
|
79
|
+
let r;
|
|
80
|
+
try {
|
|
81
|
+
r = await runTurn(orgId, { agent: opts.agent, message, image: opts.image, clear: opts.clear, sender: opts.sender, timeout: opts.timeout });
|
|
82
|
+
} catch (e) { console.error(`Send failed: ${e.message}`); process.exit(1); }
|
|
83
|
+
if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
84
|
+
console.log(`# org=${r.org?.name} agent=${r.agent?.name}${r.agent?.is_active ? " (active)" : " (override)"} contact=${r.contact?.id}`);
|
|
85
|
+
printTurn({ title: "send", tq: "" }, 0, message, r, []);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function runPack(orgId, pack, opts) {
|
|
89
|
+
const scenarios = pack.scenarios || pack;
|
|
90
|
+
const clearBetween = !opts.noClearBetween;
|
|
91
|
+
console.log(`# ${pack.name || "pack"} — ${scenarios.length} scenarios · org ${orgId}`);
|
|
92
|
+
const results = [];
|
|
93
|
+
for (let s = 0; s < scenarios.length; s++) {
|
|
94
|
+
const sc = scenarios[s];
|
|
95
|
+
if (clearBetween) {
|
|
96
|
+
try { await clearConv(orgId, opts.agent); } catch (e) { console.error(` (clear failed: ${e.message})`); }
|
|
97
|
+
}
|
|
98
|
+
const scResult = { id: sc.id, tq: sc.tq, title: sc.title, turns: [] };
|
|
99
|
+
for (let t = 0; t < sc.turns.length; t++) {
|
|
100
|
+
const turn = sc.turns[t];
|
|
101
|
+
let r;
|
|
102
|
+
try {
|
|
103
|
+
r = await runTurn(orgId, { agent: opts.agent, message: turn.text, sender: opts.sender, timeout: opts.timeout });
|
|
104
|
+
} catch (e) {
|
|
105
|
+
r = { status: "error", reply: null, tool_calls: [], raw: { error: e.message } };
|
|
106
|
+
}
|
|
107
|
+
const checks = checkExpectations(turn, r.reply);
|
|
108
|
+
scResult.turns.push({ ...r, checks });
|
|
109
|
+
if (!opts.json) printTurn(sc, t, turn.text, r, checks);
|
|
110
|
+
}
|
|
111
|
+
results.push(scResult);
|
|
112
|
+
}
|
|
113
|
+
let pass = 0, fail = 0;
|
|
114
|
+
for (const sc of results) for (const t of sc.turns) for (const c of t.checks || []) (c.ok ? pass++ : fail++);
|
|
115
|
+
if (opts.json) console.log(JSON.stringify(results, null, 2));
|
|
116
|
+
else console.log(`\n━━ assertions: ${pass} passed, ${fail} failed ━━`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function scenario(orgId, file, opts = {}) {
|
|
120
|
+
requireOrg(orgId);
|
|
121
|
+
const filePath = path.isAbsolute(file) ? file : path.resolve(process.cwd(), file);
|
|
122
|
+
let pack;
|
|
123
|
+
try { pack = JSON.parse(await fs.readFile(filePath, "utf8")); }
|
|
124
|
+
catch (e) { console.error(`Cannot read scenario file ${filePath}: ${e.message}`); process.exit(1); }
|
|
125
|
+
await runPack(orgId, pack, opts);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function qa(orgId, opts = {}) {
|
|
129
|
+
requireOrg(orgId);
|
|
130
|
+
const pack = JSON.parse(await fs.readFile(QA_PACK, "utf8"));
|
|
131
|
+
await runPack(orgId, pack, opts);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function clear(orgId, opts = {}) {
|
|
135
|
+
requireOrg(orgId);
|
|
136
|
+
let resp;
|
|
137
|
+
try { resp = await clearConv(orgId, opts.agent); }
|
|
138
|
+
catch (e) { console.error(`Clear failed: ${e.message}`); process.exit(1); }
|
|
139
|
+
console.log(`Cleared web conversation for contact ${resp.contact_id} (history hidden via memory_cutoff; onboarding/disclaimer state NOT reset).`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------- phase 2: stress + extract ----------
|
|
143
|
+
|
|
144
|
+
async function pool(items, limit, worker) {
|
|
145
|
+
const results = new Array(items.length);
|
|
146
|
+
let next = 0;
|
|
147
|
+
async function lane() {
|
|
148
|
+
while (next < items.length) {
|
|
149
|
+
const i = next++;
|
|
150
|
+
try { results[i] = await worker(items[i], i); }
|
|
151
|
+
catch (e) { results[i] = { id: items[i].id, error: String(e?.message || e) }; }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
|
|
155
|
+
return results;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function stress(orgId, file, opts = {}) {
|
|
159
|
+
requireOrg(orgId);
|
|
160
|
+
const packFile = file || "scenarios.stress.json";
|
|
161
|
+
const filePath = path.isAbsolute(packFile) ? packFile : path.resolve(process.cwd(), packFile);
|
|
162
|
+
let pack;
|
|
163
|
+
try { pack = JSON.parse(await fs.readFile(filePath, "utf8")); }
|
|
164
|
+
catch (e) { console.error(`Cannot read stress pack ${filePath}: ${e.message}`); process.exit(1); }
|
|
165
|
+
const scenarios = pack.scenarios || pack;
|
|
166
|
+
|
|
167
|
+
let head;
|
|
168
|
+
try { head = await http.get("agent-stress", { organization_id: orgId, agent_id: opts.agent }); }
|
|
169
|
+
catch (e) { console.error(`Resolve failed: ${e.message}`); process.exit(1); }
|
|
170
|
+
|
|
171
|
+
const fixedContact = opts.contact || null;
|
|
172
|
+
const concurrency = fixedContact ? 1 : (opts.concurrency ? Number.parseInt(opts.concurrency, 10) : 6);
|
|
173
|
+
const timeout = opts.timeout;
|
|
174
|
+
const t0 = Date.now();
|
|
175
|
+
console.error(`# ${pack.name || packFile}: ${scenarios.length} scenarios · org=${head.org.name} agent=${head.agent.name} · concurrency=${concurrency}`);
|
|
176
|
+
|
|
177
|
+
let done = 0;
|
|
178
|
+
const results = await pool(scenarios, concurrency, async (sc) => {
|
|
179
|
+
let contactId = fixedContact;
|
|
180
|
+
if (!contactId) {
|
|
181
|
+
const ec = await http.post("agent-stress", { action: "ensure_contact", organization_id: orgId, agent_id: opts.agent, scenario_id: sc.id, fresh: !!sc.fresh });
|
|
182
|
+
contactId = ec.contact_id;
|
|
183
|
+
}
|
|
184
|
+
const turns = [];
|
|
185
|
+
for (let i = 0; i < sc.turns.length; i++) {
|
|
186
|
+
const r = await http.post("agent-stress", {
|
|
187
|
+
action: "turn", organization_id: orgId, agent_id: opts.agent, contact_id: contactId,
|
|
188
|
+
text: sc.turns[i].text, sender: "Stress Tester", idx: `${sc.id}_${i}`, timeout_ms: timeout, reenable_bot: !!fixedContact,
|
|
189
|
+
});
|
|
190
|
+
turns.push(r);
|
|
191
|
+
}
|
|
192
|
+
const rec = { id: sc.id, category: sc.category, severity: sc.severity || "medium", tq: sc.tq || null, title: sc.title, criteria: sc.criteria, fresh: !!sc.fresh, contactId, turns };
|
|
193
|
+
done++;
|
|
194
|
+
const last = rec.turns[rec.turns.length - 1];
|
|
195
|
+
const snippet = last?.reply ? last.reply.replace(/\s+/g, " ").slice(0, 70) : `⚠ ${last?.status}`;
|
|
196
|
+
console.error(` [${done}/${scenarios.length}] ${String(rec.severity).toUpperCase().padEnd(8)} ${String(rec.id).padEnd(22)} ${snippet}`);
|
|
197
|
+
return rec;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const outPath = opts.out ? path.resolve(opts.out) : path.resolve(process.cwd(), ".flowiq", "agent-test", "stress-results.json");
|
|
201
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
202
|
+
const payload = {
|
|
203
|
+
org: head.org, agent: head.agent, ran_at: new Date().toISOString(),
|
|
204
|
+
duration_ms: Date.now() - t0, scenario_count: scenarios.length, results,
|
|
205
|
+
};
|
|
206
|
+
await fs.writeFile(outPath, JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
207
|
+
const noReply = results.filter((r) => r.turns?.some((t) => !t.reply)).length;
|
|
208
|
+
console.error(`\n# done in ${Math.round((Date.now() - t0) / 1000)}s · ${results.length} scenarios · ${noReply} with a no-reply turn · → ${outPath}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function cleanup(orgId, opts = {}) {
|
|
212
|
+
requireOrg(orgId);
|
|
213
|
+
if (!opts.confirm) {
|
|
214
|
+
console.error("Refusing to delete stress contacts without --confirm (this DELETEs contacts + their messages/sessions).");
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
let resp;
|
|
218
|
+
try { resp = await http.post("agent-stress", { action: "cleanup", organization_id: orgId, agent_id: opts.agent, confirm: true }); }
|
|
219
|
+
catch (e) { console.error(`Cleanup failed: ${e.message}`); process.exit(1); }
|
|
220
|
+
console.error(`Cleaned up ${resp.deleted} stress contact${resp.deleted === 1 ? "" : "s"} + their messages/sessions.`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function extract(orgId, opts = {}) {
|
|
224
|
+
requireOrg(orgId);
|
|
225
|
+
let resp;
|
|
226
|
+
try { resp = await http.post("agent-stress", { action: "extract", organization_id: orgId, agent_id: opts.agent }); }
|
|
227
|
+
catch (e) { console.error(`Extract failed: ${e.message}`); process.exit(1); }
|
|
228
|
+
const outPath = opts.out ? path.resolve(opts.out) : path.resolve(process.cwd(), ".flowiq", "agent-test", "judge_input.json");
|
|
229
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
230
|
+
await fs.writeFile(outPath, JSON.stringify(resp.scenarios, null, 2) + "\n", "utf8");
|
|
231
|
+
console.log(`Wrote ${outPath} — ${resp.scenarios.length} scenario transcript${resp.scenarios.length === 1 ? "" : "s"}.`);
|
|
232
|
+
}
|