@velaro/cli 1.4.57 → 1.4.58

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 CHANGED
@@ -104,6 +104,17 @@ client using the old key; the command does not edit client configurations.
104
104
  velaro mcp-key rotate 2
105
105
  ```
106
106
 
107
+ ### `velaro routing <subcommand>`
108
+ Auto-routing policy and the agent inactivity chain (v20 only).
109
+
110
+ ```bash
111
+ velaro routing policy get # includes reassignment mode + chain
112
+ velaro routing policy set --reassignment-mode Reassign --chain true --backup-team 5 --backup-after 120 --no-agent-after 300 --terminal-action EndChat
113
+ velaro routing simulate --no-agent-seconds 400 --step 1 # DRY RUN, nothing is sent
114
+ velaro routing precedence # which rule wins + conflict warnings
115
+ velaro routing inactivity-transfers --days 30 --agent-name Sam
116
+ ```
117
+
107
118
  ### `velaro kb article <subcommand>`
108
119
  Manage your help.velaro.com knowledge base articles. Full reference (including the REST/MCP equivalents and the screenshot pipeline): [`docs/architecture/kb-publishing-system.md`](../docs/architecture/kb-publishing-system.md).
109
120
 
@@ -1,4 +1,8 @@
1
- import { messagingGet, messagingPut, messagingDel, get as adminGet } from '../api.js';
1
+ import { messagingGet, messagingPut, messagingPost, messagingDel, get as adminGet } from '../api.js';
2
+ import {
3
+ TERMINAL_ACTION_NAMES, buildPolicyUpdate, validatePolicyUpdate, describeChain,
4
+ describeSimulation, describeTransfers,
5
+ } from '../routing-inactivity.js';
2
6
 
3
7
  // ── helpers ───────────────────────────────────────────────────────────────────
4
8
 
@@ -26,8 +30,18 @@ const TERMINAL_LABELS = {
26
30
  3: 'Queue',
27
31
  4: 'Offline Message',
28
32
  5: 'No Action',
33
+ 6: 'End Chat',
34
+ 7: 'Notify Manager',
35
+ 8: 'Run Workflow',
29
36
  };
30
37
 
38
+ // The API returns the terminal action as a NAME (e.g. "EndChat"); older builds returned an int.
39
+ function terminalText(v) {
40
+ if (v == null) return 'Default (Survey)';
41
+ if (typeof v === 'number') return TERMINAL_LABELS[v] ?? String(v);
42
+ return String(v);
43
+ }
44
+
31
45
  // ── velaro routing engine get ─────────────────────────────────────────────────
32
46
 
33
47
  const engineGetCommand = {
@@ -129,10 +143,8 @@ const policyGetCommand = {
129
143
  console.log(` ${DIM}Agent follow-up flag: ${RESET}${p.agentFollowUpFlagEnabled ? `${GRN}yes${RESET}` : 'no'}`);
130
144
  console.log(` ${DIM}Queue unavailable grace: ${RESET}${p.queueUnavailableGracePeriodSeconds}s`);
131
145
 
132
- const terminalLabel = p.terminalActionOverride != null
133
- ? (TERMINAL_LABELS[p.terminalActionOverride] ?? String(p.terminalActionOverride))
134
- : 'Default (Survey)';
135
- console.log(` ${DIM}Terminal action: ${RESET}${terminalLabel}`);
146
+ console.log(` ${DIM}Terminal action: ${RESET}${terminalText(p.terminalActionOverride)}`);
147
+ for (const line of describeChain(p)) console.log(` ${line}`);
136
148
  console.log();
137
149
  },
138
150
  };
@@ -168,26 +180,37 @@ const policySetCommand = {
168
180
  describe: 'Prefer previous agent on timeout (true/false)',
169
181
  })
170
182
  .option('terminal-action', {
171
- type: 'number',
172
- describe: 'Terminal action: 0=Survey 1=CreateTicket 2=SurveyAndCreateTicket 3=Queue 4=OfflineMessage 5=NoOp',
173
- }),
183
+ type: 'string',
184
+ describe: `Terminal action, name or 0-8: ${TERMINAL_ACTION_NAMES.map((n, i) => `${i}=${n}`).join(' ')}`,
185
+ })
186
+ .option('exhaustion-message', { type: 'string', describe: 'Visitor message when the final action runs' })
187
+ .option('reassignment-mode', {
188
+ type: 'string', choices: ['Observe', 'QueueOnly', 'Reassign'],
189
+ describe: 'Observe = log only (default), QueueOnly = return chat to the queue, Reassign = move to another agent',
190
+ })
191
+ .option('chain', { type: 'boolean', describe: 'Turn the agent inactivity chain on/off' })
192
+ .option('backup-team', { type: 'number', describe: 'Backup group (team) id to offer the chat to' })
193
+ .option('backup-after', { type: 'number', describe: 'Seconds with no same-group agent before the backup group (0-7200)' })
194
+ .option('no-agent-after', { type: 'number', describe: 'Seconds with no agent available before the final action (0-14400)' })
195
+ .option('presence-trigger', { type: 'boolean', describe: 'Reroute when the agent goes Away/Offline' })
196
+ .option('alert-on-release', { type: 'boolean', describe: 'Alert available agents when a chat is released' })
197
+ .option('notify-managers', { type: 'boolean', describe: 'Notify managers on inactivity' })
198
+ .option('cooldown', { type: 'number', describe: 'Seconds between moves of the same chat (0-3600)' })
199
+ .option('queue-escalation-minutes', { type: 'number', describe: 'Minutes in queue before escalation alert (1-240)' })
200
+ .option('terminal-workflow', { type: 'number', describe: 'Workflow id to run when the terminal action is RunWorkflow' })
201
+ .option('visitor-text', { type: 'string', describe: 'Visitor status text (max 500). Tokens: {{releasedFromAgent}} {{reason}} {{teamName}}' })
202
+ .option('agent-text', { type: 'string', describe: 'Agent status text (max 500)' }),
174
203
  handler: async (argv) => {
175
204
  // Load current policy first, then merge
176
205
  const current = await messagingGet('/AutoRoutingPolicy');
177
206
 
178
- const updated = {
179
- ...current,
180
- initialResponseTimeoutSeconds: argv['initial-timeout'] ?? current.initialResponseTimeoutSeconds,
181
- idleAfterResponseTimeoutSeconds: argv['idle-timeout'] ?? current.idleAfterResponseTimeoutSeconds,
182
- idleAlertGraceSeconds: argv['grace'] ?? current.idleAlertGraceSeconds,
183
- maxRerouteAttempts: argv['max-reroutes'] ?? current.maxRerouteAttempts,
184
- allowBounceBack: argv['allow-bounceback'] ?? current.allowBounceBack,
185
- preferPreviousAgentOnTimeout: argv['prefer-previous'] ?? current.preferPreviousAgentOnTimeout,
186
- terminalActionOverride:
187
- argv['terminal-action'] !== undefined
188
- ? argv['terminal-action']
189
- : current.terminalActionOverride,
190
- };
207
+ const updated = buildPolicyUpdate(current, argv);
208
+ const problems = validatePolicyUpdate(updated);
209
+ if (problems.length) {
210
+ for (const m of problems) console.error(` ${RED}x${RESET} ${m}`);
211
+ process.exitCode = 1;
212
+ return;
213
+ }
191
214
 
192
215
  await messagingPut('/AutoRoutingPolicy', updated);
193
216
  console.log(`\n ${GRN}✓${RESET} Routing policy updated.\n`);
@@ -279,7 +302,7 @@ const explainCommand = {
279
302
  console.log(` Initial timeout: ${p.initialResponseTimeoutSeconds}s`);
280
303
  console.log(` Idle timeout: ${p.idleAfterResponseTimeoutSeconds}s`);
281
304
  console.log(` Max reroutes: ${p.maxRerouteAttempts}`);
282
- console.log(` Terminal action: ${p.terminalActionOverride != null ? TERMINAL_LABELS[p.terminalActionOverride] ?? p.terminalActionOverride : 'default (Survey)'}`);
305
+ console.log(` Terminal action: ${terminalText(p.terminalActionOverride)}`);
283
306
  console.log(` Anti-bounce: ${!p.allowBounceBack ? `${GRN}ON${RESET}` : `${DIM}off${RESET}`}`);
284
307
  console.log(` Prefer prev agent: ${p.preferPreviousAgentOnTimeout ? `${GRN}yes${RESET}` : 'no'}`);
285
308
  }
@@ -287,6 +310,76 @@ const explainCommand = {
287
310
  },
288
311
  };
289
312
 
313
+ // ── velaro routing simulate ───────────────────────────────────────────────────
314
+
315
+ const simulateCommand = {
316
+ command: 'simulate',
317
+ describe: 'DRY RUN: what would the agent inactivity chain do for a mocked scenario (sends nothing)',
318
+ builder: (y) => y
319
+ .option('team', { type: 'number', describe: 'Group id whose policy to simulate (default: site default)' })
320
+ .option('no-agent-seconds', { type: 'number', default: 0, describe: 'Seconds nobody has been available' })
321
+ .option('attempts-used', { type: 'number', default: 0, describe: 'Moves already used on the chat' })
322
+ .option('step', { type: 'number', default: 0, describe: '0 nothing tried, 1 backup group offered, 2 final action applied' })
323
+ .option('since-last-transfer', { type: 'number', describe: 'Seconds since the chat was last moved' })
324
+ .option('already-handled', { type: 'boolean', default: false, describe: 'Simulate the same silence delivered twice' })
325
+ .option('agent-name', { type: 'string', describe: 'Silent agent name for the alert text' }),
326
+ handler: async (argv) => {
327
+ const r = await messagingPost('/Routing/Inactivity/simulate', {
328
+ teamId: argv.team ?? null,
329
+ noAgentSeconds: argv['no-agent-seconds'],
330
+ attemptsUsed: argv['attempts-used'],
331
+ inactivityStep: argv.step,
332
+ secondsSinceLastTransfer: argv['since-last-transfer'] ?? null,
333
+ sameEpisodeAlreadyHandled: argv['already-handled'],
334
+ fromAgentName: argv['agent-name'] ?? null,
335
+ });
336
+ console.log();
337
+ for (const l of describeSimulation(r)) console.log(` ${l}`);
338
+ console.log();
339
+ },
340
+ };
341
+
342
+ // ── velaro routing precedence ─────────────────────────────────────────────────
343
+
344
+ const precedenceCommand = {
345
+ command: 'precedence',
346
+ describe: 'Which routing rule wins when an agent goes silent, plus policy conflict warnings',
347
+ builder: (y) => y,
348
+ handler: async () => {
349
+ const [defaults, conflicts] = await Promise.all([
350
+ messagingGet('/Routing/Inactivity/defaults'),
351
+ messagingGet('/Routing/Inactivity/conflicts'),
352
+ ]);
353
+ console.log(`\n${BOLD}Evaluation order (top to bottom)${RESET}`);
354
+ for (const p of defaults.precedence ?? []) console.log(` ${p}`);
355
+ const list = conflicts.conflicts ?? [];
356
+ console.log(`\n${BOLD}Warnings${RESET}`);
357
+ if (!list.length) console.log(' None. No group policy cancels out the site default.');
358
+ for (const c of list) console.log(` [${String(c.severity).toUpperCase()}] ${c.teamId != null ? `group ${c.teamId}` : 'site default'}: ${c.message}`);
359
+ console.log();
360
+ },
361
+ };
362
+
363
+ // ── velaro routing inactivity-transfers ───────────────────────────────────────
364
+
365
+ const inactivityTransfersCommand = {
366
+ command: 'inactivity-transfers',
367
+ describe: 'Count agent inactivity transfers (chats taken from a silent agent), total, per agent, per day',
368
+ builder: (y) => y
369
+ .option('days', { type: 'number', default: 30, describe: 'Lookback in days (1-92)' })
370
+ .option('agent-name', { type: 'string', describe: 'Agent display name (partial match)' })
371
+ .option('agent-id', { type: 'number', describe: 'Exact agent id' }),
372
+ handler: async (argv) => {
373
+ let path = `RoutingDiagnostics/inactivity-transfers?days=${argv.days}`;
374
+ if (argv['agent-id'] != null) path += `&agentId=${argv['agent-id']}`;
375
+ if (argv['agent-name']) path += `&agentName=${encodeURIComponent(argv['agent-name'])}`;
376
+ const r = await adminGet(path);
377
+ console.log();
378
+ for (const l of describeTransfers(r)) console.log(` ${l}`);
379
+ console.log();
380
+ },
381
+ };
382
+
290
383
  // ── top-level routing command ─────────────────────────────────────────────────
291
384
 
292
385
  export const routingCommand = {
@@ -298,7 +391,10 @@ export const routingCommand = {
298
391
  .command(policyCommand)
299
392
  .command(decisionLogCommand)
300
393
  .command(explainCommand)
301
- .demandCommand(1, 'Specify a subcommand: engine, circuit-breaker, policy, decision-log, explain')
394
+ .command(simulateCommand)
395
+ .command(precedenceCommand)
396
+ .command(inactivityTransfersCommand)
397
+ .demandCommand(1, 'Specify a subcommand: engine, circuit-breaker, policy, decision-log, explain, simulate, precedence, inactivity-transfers')
302
398
  .strict(),
303
399
  handler: () => {},
304
400
  };
@@ -0,0 +1,160 @@
1
+ // Pure logic for `velaro routing ...` agent inactivity chain commands (velaro-messaging PR #1471).
2
+ // v20 ONLY. No I/O here so it can be unit tested (see routing-inactivity.test.js).
3
+
4
+ // Persisted as ints by messaging (append-only enum); the API accepts and returns the NAME.
5
+ export const TERMINAL_ACTION_NAMES = [
6
+ 'Survey', 'CreateTicket', 'SurveyAndCreateTicket', 'Queue', 'OfflineMessage', 'NoOp',
7
+ 'EndChat', 'NotifyManager', 'RunWorkflow',
8
+ ];
9
+
10
+ export function terminalLabel(v) {
11
+ if (v === null || v === undefined || v === '') return 'Default (Survey)';
12
+ if (typeof v === 'number') return TERMINAL_ACTION_NAMES[v] ?? String(v);
13
+ return String(v);
14
+ }
15
+
16
+ /** Accepts an int (0-8) or a name (any case). Returns the enum name, or null for "default". Throws on junk. */
17
+ export function terminalForSave(v) {
18
+ if (v === null || v === undefined || v === '') return null;
19
+ if (typeof v === 'number' || /^\d+$/.test(String(v))) {
20
+ const name = TERMINAL_ACTION_NAMES[Number(v)];
21
+ if (!name) throw new Error(`Unknown terminal action ${v}. Use 0-8: ${TERMINAL_ACTION_NAMES.map((n, i) => `${i}=${n}`).join(' ')}`);
22
+ return name;
23
+ }
24
+ const hit = TERMINAL_ACTION_NAMES.find(n => n.toLowerCase() === String(v).toLowerCase());
25
+ if (!hit) throw new Error(`Unknown terminal action "${v}". Use one of: ${TERMINAL_ACTION_NAMES.join(', ')}`);
26
+ return hit;
27
+ }
28
+
29
+ // CLI flag -> AutoRoutingPolicy.InactivityChain field
30
+ export const CHAIN_FLAGS = {
31
+ 'chain': 'enabled',
32
+ 'backup-team': 'backupTeamId',
33
+ 'backup-after': 'backupTeamAfterSeconds',
34
+ 'no-agent-after': 'noTargetActionAfterSeconds',
35
+ 'presence-trigger': 'rerouteOnAgentUnavailable',
36
+ 'alert-on-release': 'alertAvailableAgentsOnRelease',
37
+ 'notify-managers': 'notifyManagersOnInactivity',
38
+ 'cooldown': 'rerouteCooldownSeconds',
39
+ 'queue-escalation-minutes': 'queueEscalationAfterMinutes',
40
+ 'terminal-workflow': 'terminalWorkflowId',
41
+ 'visitor-text': 'visitorStatusText',
42
+ 'agent-text': 'agentStatusText',
43
+ };
44
+
45
+ /**
46
+ * Merges CLI flags onto the current policy and returns the PUT body.
47
+ * reassignmentMode is ALWAYS sent (an absent value is treated as Observe by the API and would silently
48
+ * switch reassignment off). The chain block is only sent when the server reports one or a chain flag is set.
49
+ */
50
+ export function buildPolicyUpdate(current, argv) {
51
+ const merged = { ...current };
52
+ const map = {
53
+ 'initial-timeout': 'initialResponseTimeoutSeconds',
54
+ 'idle-timeout': 'idleAfterResponseTimeoutSeconds',
55
+ 'grace': 'idleAlertGraceSeconds',
56
+ 'max-reroutes': 'maxRerouteAttempts',
57
+ 'allow-bounceback': 'allowBounceBack',
58
+ 'prefer-previous': 'preferPreviousAgentOnTimeout',
59
+ 'exhaustion-message': 'exhaustionMessageTemplate',
60
+ };
61
+ for (const [flag, field] of Object.entries(map)) if (argv[flag] !== undefined) merged[field] = argv[flag];
62
+
63
+ merged.terminalActionOverride = terminalForSave(
64
+ argv['terminal-action'] !== undefined ? argv['terminal-action'] : current.terminalActionOverride
65
+ );
66
+ // Actions 6-8 exist only on a messaging build with the inactivity chain; an older one would store a different value.
67
+ if (['EndChat', 'NotifyManager', 'RunWorkflow'].includes(merged.terminalActionOverride) && !('inactivityChain' in current))
68
+ throw new Error(`Terminal action ${merged.terminalActionOverride} needs a messaging build with the agent inactivity chain; this one does not report it. Use 0-5.`);
69
+
70
+ const mode = argv['reassignment-mode'] ?? current.reassignmentMode ?? 'Observe';
71
+ if (!['Observe', 'QueueOnly', 'Reassign'].includes(mode)) {
72
+ throw new Error(`Unknown reassignment mode "${mode}". Use Observe, QueueOnly or Reassign.`);
73
+ }
74
+ merged.reassignmentMode = mode;
75
+
76
+ const patched = Object.keys(CHAIN_FLAGS).filter(f => argv[f] !== undefined);
77
+ if (current.inactivityChain || patched.length) {
78
+ const chain = { ...(current.inactivityChain ?? {}) };
79
+ for (const f of patched) chain[CHAIN_FLAGS[f]] = argv[f];
80
+ merged.inactivityChain = chain;
81
+ }
82
+ return merged;
83
+ }
84
+
85
+ /** Client-side mirror of the server's cross-field rules, so the CLI fails before the round trip. */
86
+ export function validatePolicyUpdate(p) {
87
+ const errors = [];
88
+ const c = p.inactivityChain;
89
+ // RunWorkflow with no workflow is invalid whatever the chain switch says: the final action also runs
90
+ // when the chain is off (hop cap reached), so it would have nothing to run.
91
+ if (p.terminalActionOverride === 'RunWorkflow' && (!c || c.terminalWorkflowId == null))
92
+ errors.push('Terminal action RunWorkflow needs --terminal-workflow <id>.');
93
+ if (!c || !c.enabled) return errors;
94
+ if ((c.backupTeamId != null || c.noTargetActionAfterSeconds != null) && p.reassignmentMode !== 'Reassign')
95
+ errors.push("The backup group and no-agent-available steps need --reassignment-mode Reassign.");
96
+ if (c.backupTeamId != null && c.noTargetActionAfterSeconds != null && c.noTargetActionAfterSeconds < (c.backupTeamAfterSeconds ?? 120))
97
+ errors.push('--no-agent-after must be at least as long as --backup-after (the backup group step runs first).');
98
+ return errors;
99
+ }
100
+
101
+ export function describeChain(p) {
102
+ const c = p.inactivityChain;
103
+ if (!c) return ['(This messaging build does not report the inactivity chain.)'];
104
+ const out = [
105
+ `Reassignment mode: ${p.reassignmentMode ?? 'Observe'}`,
106
+ `Inactivity chain: ${c.enabled ? 'ON' : 'off'}`,
107
+ ];
108
+ if (c.enabled) {
109
+ out.push(
110
+ ` Backup group: ${c.backupTeamId ?? 'none'}${c.backupTeamId != null ? ` after ${c.backupTeamAfterSeconds}s` : ''}`,
111
+ ` Final action after: ${c.noTargetActionAfterSeconds != null ? `${c.noTargetActionAfterSeconds}s with no agent available` : 'only when the hop cap is used up'}`,
112
+ ` Cooldown: ${c.rerouteCooldownSeconds}s`,
113
+ ` Presence trigger: ${c.rerouteOnAgentUnavailable}`,
114
+ ` Alert on release: ${c.alertAvailableAgentsOnRelease}`,
115
+ ` Notify managers: ${c.notifyManagersOnInactivity}`,
116
+ ` Queue escalation: ${c.queueEscalationAfterMinutes != null ? `${c.queueEscalationAfterMinutes} min` : 'never'}`,
117
+ ` Terminal workflow: ${c.terminalWorkflowId ?? 'none'}`,
118
+ );
119
+ }
120
+ return out;
121
+ }
122
+
123
+ const GATE = {
124
+ NotEnabled: "The inactivity chain is off for this rule, so today's behavior applies and nothing new happens.",
125
+ Proceed: 'Allowed to move the chat.',
126
+ SkipDedupe: 'Blocked: this exact silence was already handled (double-transfer guard).',
127
+ SkipCooldown: 'Blocked: the chat was moved too recently (cooldown).',
128
+ HopCapReached: 'Hop cap reached: no more moves, so the final action applies.',
129
+ };
130
+ const ACTION = {
131
+ LegacyDefer: 'Nothing new is configured, so the chat stays with its current owner.',
132
+ Hold: 'Still inside the wait window, nothing happens yet.',
133
+ WidenToBackupTeam: 'The chat is offered to the backup group.',
134
+ ApplyTerminal: 'The final action runs.',
135
+ };
136
+
137
+ export function describeSimulation(r) {
138
+ const lines = [
139
+ 'DRY RUN (nothing sent, no live chat touched)',
140
+ `Settings used: ${r.ruleSource}`,
141
+ `Safety gate: ${GATE[r.gate] ?? r.gate}`,
142
+ `Decision: ${ACTION[r.noTargetAction] ?? r.noTargetAction}`,
143
+ ];
144
+ if (r.explanation) lines.push(`Why: ${r.explanation}`);
145
+ if (r.nextStepAtSeconds != null) lines.push(`Next step after ${r.nextStepAtSeconds}s with no agent available.`);
146
+ lines.push(`Visitor would see: "${r.visitorMessage}"`, `Agents would see: "${r.queueAlert || r.agentStatus}"`);
147
+ for (const l of r.otherRulesThatMatchedButLost ?? []) lines.push(`Also matched but lost: ${l.rule} (${l.reason})`);
148
+ return lines;
149
+ }
150
+
151
+ export function describeTransfers(r) {
152
+ if (r.note && !r.total && !(r.byAgent ?? []).length) return [r.note];
153
+ const lines = [`Agent inactivity transfers, site ${r.siteId}, last ${r.days} days: ${r.total}${r.capped ? ' (capped at 5000)' : ''}`];
154
+ if (r.note) lines.push(r.note);
155
+ if (r.matchedAgentsCapped) lines.push('The name matched more agents than are counted here. Use a longer name or --agent-id.');
156
+ for (const a of r.byAgent ?? []) lines.push(` ${a.name}${a.agentId != null ? ` (id ${a.agentId})` : ''}: ${a.count}`);
157
+ if ((r.byDay ?? []).length) lines.push('', 'By day (Pacific): ' + r.byDay.map(d => `${d.date}=${d.count}`).join(', '));
158
+ if (!r.total) lines.push('None. Check that the inactivity chain is on and reassignment mode is not Observe.');
159
+ return lines;
160
+ }
@@ -0,0 +1,101 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ buildPolicyUpdate, validatePolicyUpdate, terminalForSave, terminalLabel,
5
+ describeChain, describeSimulation, describeTransfers,
6
+ } from './routing-inactivity.js';
7
+
8
+ const current = {
9
+ enabled: true, maxRerouteAttempts: 3, terminalActionOverride: 'Survey', reassignmentMode: 'Reassign',
10
+ inactivityChain: { enabled: true, backupTeamId: 5, backupTeamAfterSeconds: 120, noTargetActionAfterSeconds: 300, rerouteCooldownSeconds: 60 },
11
+ };
12
+
13
+ test('terminal action accepts ints and names, rejects junk', () => {
14
+ assert.equal(terminalForSave(6), 'EndChat');
15
+ assert.equal(terminalForSave('7'), 'NotifyManager');
16
+ assert.equal(terminalForSave('runworkflow'), 'RunWorkflow');
17
+ assert.equal(terminalForSave(undefined), null);
18
+ assert.throws(() => terminalForSave(9), /Unknown terminal action/);
19
+ assert.throws(() => terminalForSave('Bogus'), /Unknown terminal action/);
20
+ assert.equal(terminalLabel(null), 'Default (Survey)');
21
+ assert.equal(terminalLabel(8), 'RunWorkflow');
22
+ });
23
+
24
+ test('an update with no chain flags keeps the saved mode and chain (mode is always sent)', () => {
25
+ const body = buildPolicyUpdate(current, { 'max-reroutes': 4 });
26
+ assert.equal(body.maxRerouteAttempts, 4);
27
+ assert.equal(body.reassignmentMode, 'Reassign');
28
+ assert.deepEqual(body.inactivityChain, current.inactivityChain);
29
+ assert.equal(body.terminalActionOverride, 'Survey');
30
+ });
31
+
32
+ test('a server without reassignmentMode still sends Observe explicitly and no chain block', () => {
33
+ const body = buildPolicyUpdate({ enabled: true }, { 'max-reroutes': 2 });
34
+ assert.equal(body.reassignmentMode, 'Observe');
35
+ assert.equal('inactivityChain' in body, false);
36
+ });
37
+
38
+ test('chain flags patch the chain block', () => {
39
+ const body = buildPolicyUpdate(current, { 'cooldown': 90, 'chain': false, 'visitor-text': 'Hi' });
40
+ assert.equal(body.inactivityChain.rerouteCooldownSeconds, 90);
41
+ assert.equal(body.inactivityChain.enabled, false);
42
+ assert.equal(body.inactivityChain.visitorStatusText, 'Hi');
43
+ assert.equal(body.inactivityChain.backupTeamId, 5);
44
+ });
45
+
46
+ test('chain flags create the block on a server that had none', () => {
47
+ const body = buildPolicyUpdate({ enabled: true }, { chain: true, 'backup-team': 5, 'reassignment-mode': 'Reassign' });
48
+ assert.equal(body.inactivityChain.enabled, true);
49
+ assert.equal(body.reassignmentMode, 'Reassign');
50
+ });
51
+
52
+ test('rejects an unknown reassignment mode', () => {
53
+ assert.throws(() => buildPolicyUpdate(current, { 'reassignment-mode': 'Yolo' }), /Unknown reassignment mode/);
54
+ });
55
+
56
+ test('validation mirrors the server cross-field rules', () => {
57
+ assert.deepEqual(validatePolicyUpdate(buildPolicyUpdate(current, {})), []);
58
+ assert.match(validatePolicyUpdate(buildPolicyUpdate(current, { 'reassignment-mode': 'QueueOnly' }))[0], /Reassign/);
59
+ assert.match(validatePolicyUpdate(buildPolicyUpdate(current, { 'backup-after': 600 }))[0], /at least as long/);
60
+ assert.match(validatePolicyUpdate(buildPolicyUpdate(current, { 'terminal-action': 'RunWorkflow' }))[0], /--terminal-workflow/);
61
+ assert.deepEqual(validatePolicyUpdate(buildPolicyUpdate(current, { chain: false, 'reassignment-mode': 'Observe' })), []);
62
+ // RunWorkflow without a workflow is rejected even with the chain off (the final action still runs at the hop cap).
63
+ assert.match(
64
+ validatePolicyUpdate(buildPolicyUpdate(current, { chain: false, 'reassignment-mode': 'Observe', 'terminal-action': 'RunWorkflow' }))[0],
65
+ /--terminal-workflow/
66
+ );
67
+ assert.deepEqual(
68
+ validatePolicyUpdate(buildPolicyUpdate(current, { 'terminal-action': 'RunWorkflow', 'terminal-workflow': 9 })),
69
+ []
70
+ );
71
+ });
72
+
73
+ test('terminal actions 6-8 are rejected when the current policy has no inactivityChain block', () => {
74
+ const legacy = { enabled: true, maxRerouteAttempts: 3, terminalActionOverride: 'Survey', reassignmentMode: 'Observe' };
75
+ assert.throws(() => buildPolicyUpdate(legacy, { 'terminal-action': 'EndChat' }), /inactivity chain/);
76
+ assert.throws(() => buildPolicyUpdate(legacy, { 'terminal-action': 8 }), /inactivity chain/);
77
+ assert.equal(buildPolicyUpdate(legacy, { 'terminal-action': 3 }).terminalActionOverride, 'Queue');
78
+ });
79
+
80
+ test('describeTransfers keeps results when a site override was ignored', () => {
81
+ const lines = describeTransfers({
82
+ siteId: 1, days: 30, total: 2, siteOverrideIgnored: true,
83
+ note: 'siteIdOverride=5 was ignored because only Velaro admins may query another site. These results are for site 1.',
84
+ byAgent: [{ agentId: 7, name: 'Sam', count: 2 }], byDay: [{ date: '2026-09-24', count: 2 }],
85
+ }).join('\n');
86
+ assert.match(lines, /was ignored/);
87
+ assert.match(lines, /Sam \(id 7\): 2/);
88
+ assert.match(lines, /By day \(Pacific\): 2026-09-24=2/);
89
+ });
90
+
91
+ test('describe helpers render the chain, a simulation and a transfer count', () => {
92
+ assert.match(describeChain(current).join('\n'), /Inactivity chain:\s+ON/);
93
+ assert.match(describeChain({}).join('\n'), /does not report/);
94
+ const sim = describeSimulation({ ruleSource: 'SiteDefault', gate: 'SkipCooldown', noTargetAction: 'Hold', explanation: 'x', nextStepAtSeconds: 60, visitorMessage: 'v', agentStatus: 'a', queueAlert: '', otherRulesThatMatchedButLost: [{ rule: 'R', reason: 'why' }] });
95
+ assert.match(sim.join('\n'), /DRY RUN/);
96
+ assert.match(sim.join('\n'), /cooldown/);
97
+ assert.match(sim.join('\n'), /Also matched but lost: R \(why\)/);
98
+ assert.match(describeTransfers({ siteId: 1, days: 30, total: 0, byAgent: [], byDay: [] }).join('\n'), /None\./);
99
+ assert.match(describeTransfers({ siteId: 1, days: 30, total: 2, byAgent: [{ agentId: 7, name: 'Sam', count: 2 }], byDay: [] }).join('\n'), /Sam \(id 7\): 2/);
100
+ assert.deepEqual(describeTransfers({ note: 'No agent found' }), ['No agent found']);
101
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/cli",
3
- "version": "1.4.57",
3
+ "version": "1.4.58",
4
4
  "description": "Velaro Workspace v20 — command-line interface for managing bots, knowledge base ingestion, MCP API keys, search indexes, and ops health.",
5
5
  "type": "module",
6
6
  "bin": {