@xema/omni-protocol 0.1.74 → 0.1.76
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/dist/index.d.ts +5 -2
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +29 -1
- package/dist/validation.d.ts +12 -0
- package/dist/validation.js +76 -4
- package/guide.md +43 -15
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -904,11 +904,14 @@ export type TaskOutcome = {
|
|
|
904
904
|
} | {
|
|
905
905
|
type: "transferred";
|
|
906
906
|
destinationId?: string;
|
|
907
|
-
}
|
|
907
|
+
}
|
|
908
|
+
/** Who called the work off: the agent declining, the provider withdrawing or re-routing, the party abandoning. */
|
|
909
|
+
| {
|
|
908
910
|
type: "cancelled";
|
|
911
|
+
by: "agent" | "provider" | "party";
|
|
909
912
|
reason?: string;
|
|
910
913
|
}
|
|
911
|
-
/** Only the phases in which somebody is still being waited on can expire
|
|
914
|
+
/** Only the phases in which somebody is still being waited on can expire; an offer that lapses at `allocationExpiresAt` names `pending`. */
|
|
912
915
|
| {
|
|
913
916
|
type: "expired";
|
|
914
917
|
phase: "pending" | "confirmed" | "preview";
|
package/dist/testing.d.ts
CHANGED
|
@@ -57,6 +57,13 @@ export interface ExerciseAdapterOptions {
|
|
|
57
57
|
* memory has nothing, and is named. Without it the store is required and never read.
|
|
58
58
|
*/
|
|
59
59
|
rebuild?: () => Adapter<Channel>;
|
|
60
|
+
/**
|
|
61
|
+
* The values the queue locks on this login's tasks -- a party's number or email -- as the run's
|
|
62
|
+
* author knows them. A task whose party stands locked may carry none of them anywhere else
|
|
63
|
+
* (`task.locked.leak`). Required once any task locks a party field: a run that cannot ask the
|
|
64
|
+
* question is not a pass, and says so by throwing rather than passing.
|
|
65
|
+
*/
|
|
66
|
+
lockedValues?: readonly string[];
|
|
60
67
|
}
|
|
61
68
|
/**
|
|
62
69
|
* Adapter conformance exercise: validates the manifest, opens an authenticated session,
|
package/dist/testing.js
CHANGED
|
@@ -163,6 +163,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
163
163
|
const breaks = new BreakStream();
|
|
164
164
|
let seeded = false;
|
|
165
165
|
const duringRead = [];
|
|
166
|
+
let lockedPartySeen = false;
|
|
166
167
|
const storedSecrets = new Map();
|
|
167
168
|
const authenticationSecrets = {
|
|
168
169
|
get: async (key) => storedSecrets.get(key),
|
|
@@ -204,6 +205,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
204
205
|
capabilities: current().capabilities,
|
|
205
206
|
loginId: context.loginId,
|
|
206
207
|
autoAcceptTasks: context.autoAcceptTasks,
|
|
208
|
+
locked: options.lockedValues,
|
|
207
209
|
});
|
|
208
210
|
// The optional methods are optional only until something declares a need for them. Each
|
|
209
211
|
// check pairs a method with the declaration that requires it, as the guide's Live-connection
|
|
@@ -341,6 +343,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
341
343
|
message: `the provider reported a diagnostic: expected ${String(envelope.event.expected)}; observed ${String(envelope.event.observed)}` });
|
|
342
344
|
}
|
|
343
345
|
violations.push(...undeterminedTasks(eventTasks(envelope), "event"));
|
|
346
|
+
lockedPartySeen ||= eventTasks(envelope).some(locksParty);
|
|
344
347
|
if (eventNamesUsers(envelope))
|
|
345
348
|
requireMethod(live, "describeUsers", "an event publishes a UserId");
|
|
346
349
|
// Work is pulled, never pushed: an offer before the host stated capacity is an allocation
|
|
@@ -427,6 +430,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
427
430
|
tellRefused({ artefact: "snapshot", violations: broken });
|
|
428
431
|
}
|
|
429
432
|
violations.push(...undeterminedTasks(Array.isArray(snapshot?.tasks) ? snapshot.tasks : [], "snapshot.tasks"));
|
|
433
|
+
lockedPartySeen ||= (Array.isArray(snapshot?.tasks) ? snapshot.tasks : []).some(locksParty);
|
|
430
434
|
stream.seed(snapshot);
|
|
431
435
|
breaks.seed(snapshot);
|
|
432
436
|
seeded = true;
|
|
@@ -587,6 +591,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
587
591
|
message: "an unsubscribe, disconnect(), or close() threw during shutdown",
|
|
588
592
|
});
|
|
589
593
|
}
|
|
594
|
+
// A task locked a party field and the run was not told the value: task.locked.leak was never
|
|
595
|
+
// asked, and a result that cannot say is not returned as one that passed.
|
|
596
|
+
if (lockedPartySeen && options.lockedValues === undefined) {
|
|
597
|
+
stopObserving();
|
|
598
|
+
throw new TypeError("a task locks its party's number or email and the run states no lockedValues: task.locked.leak cannot be evaluated; pass the values the queue locked in options.lockedValues");
|
|
599
|
+
}
|
|
590
600
|
if (!options.collectOnly)
|
|
591
601
|
assertNoViolations(violations);
|
|
592
602
|
stopObserving();
|
|
@@ -727,6 +737,13 @@ export function assertAuthenticationRestoreAndExpiry(states) {
|
|
|
727
737
|
}
|
|
728
738
|
}
|
|
729
739
|
/** The tasks an envelope carries: the one a task event names, or a snapshot event's list. */
|
|
740
|
+
/** Whether a task's party stands locked on its number or email, which is what obliges a run to state the locked values. */
|
|
741
|
+
function locksParty(task) {
|
|
742
|
+
if (!isRecord(task) || !isRecord(task.party))
|
|
743
|
+
return false;
|
|
744
|
+
const locked = (value) => isRecord(value) && value.lockedBy !== undefined;
|
|
745
|
+
return locked(task.party.number) || locked(task.party.email);
|
|
746
|
+
}
|
|
730
747
|
function eventTasks(envelope) {
|
|
731
748
|
if (!isRecord(envelope) || !isRecord(envelope.event))
|
|
732
749
|
return [];
|
|
@@ -1026,7 +1043,9 @@ export class TaskStream {
|
|
|
1026
1043
|
stages.set(entry.dialId, entry.stage);
|
|
1027
1044
|
}
|
|
1028
1045
|
}
|
|
1029
|
-
return {
|
|
1046
|
+
return { channel: String(isRecord(task) ? task.channel : undefined), completionMode: String(isRecord(task) ? task.completionMode : undefined),
|
|
1047
|
+
wrapAllowance: isRecord(task) && typeof task.wrapAllowance === "number" ? task.wrapAllowance : undefined,
|
|
1048
|
+
phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task), allocation: String(isRecord(task) ? task.allocationId : undefined) };
|
|
1030
1049
|
}
|
|
1031
1050
|
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
1032
1051
|
seed(snapshot) {
|
|
@@ -1214,6 +1233,15 @@ export class TaskStream {
|
|
|
1214
1233
|
// A late ending for a life that is over must not end the life that is open under the same id.
|
|
1215
1234
|
if (!this.namesTheOpenLife(event, known, at, refuse))
|
|
1216
1235
|
break;
|
|
1236
|
+
// Off voice there is no media event, so the update that moves a task to completing is the
|
|
1237
|
+
// provider's word that handling ended and the moment the wrap allowance starts. A provider
|
|
1238
|
+
// that completes the task itself with an allowance to run has to have started the clock:
|
|
1239
|
+
// completed from in-progress, the allowance it stated was never given.
|
|
1240
|
+
ruleEvaluated("stream.taskEnded.unwrapped");
|
|
1241
|
+
if (known.channel !== "voice" && known.completionMode === "provider-automatic" && known.wrapAllowance !== undefined && known.wrapAllowance > 0
|
|
1242
|
+
&& known.phase !== "completing" && isRecord(event.outcome) && event.outcome.type === "completed") {
|
|
1243
|
+
refuse("stream.taskEnded.unwrapped", `${at}.outcome`, `${id} was completed from ${known.phase} with a wrap allowance of ${known.wrapAllowance}s under provider-automatic: off voice, completing is the provider's word that handling ended and the allowance's start, and it was never published`);
|
|
1244
|
+
}
|
|
1217
1245
|
this.endedAllocations.add(known.allocation);
|
|
1218
1246
|
this.tasks.delete(id);
|
|
1219
1247
|
break;
|
package/dist/validation.d.ts
CHANGED
|
@@ -32,6 +32,12 @@ export interface TaskValidationContext {
|
|
|
32
32
|
autoAcceptTasks?: boolean;
|
|
33
33
|
/** Whether the manifest declares `dialOutcomes`. A task that may dial needs it to; absent, the question is not asked. */
|
|
34
34
|
dialOutcomesDeclared?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* The values the queue locked on this login's tasks -- a party's number or email -- as whoever
|
|
37
|
+
* runs the validator knows them. Where a task's party stands locked, no other field of it may
|
|
38
|
+
* carry one of these. Unknown to a host, which never sees the value, and then unchecked.
|
|
39
|
+
*/
|
|
40
|
+
locked?: readonly string[];
|
|
35
41
|
}
|
|
36
42
|
export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
|
|
37
43
|
/**
|
|
@@ -53,6 +59,12 @@ export interface ReaderContext {
|
|
|
53
59
|
levels?: readonly string[];
|
|
54
60
|
/** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
|
|
55
61
|
loginId?: string;
|
|
62
|
+
/**
|
|
63
|
+
* The values the queue locked on this login's tasks, as whoever runs the validator knows them:
|
|
64
|
+
* a conformance run states them, a host never has them. Where a task's party stands locked, no
|
|
65
|
+
* other field of it may carry one (`task.locked.leak`). Absent, unchecked.
|
|
66
|
+
*/
|
|
67
|
+
locked?: readonly string[];
|
|
56
68
|
/** `ConnectContext.autoAcceptTasks` as sent: whether a pending task states its `acceptance`. Unknown to a caller without the context, and then unchecked. */
|
|
57
69
|
autoAcceptTasks?: boolean;
|
|
58
70
|
}
|
package/dist/validation.js
CHANGED
|
@@ -89,6 +89,7 @@ const PHONES = membersOf({ softphone: true, deskPhone: true });
|
|
|
89
89
|
const TEAM_CAPABILITIES = membersOf({ breakControl: true, leadAssistControl: true, policyControl: true, monitorControl: true });
|
|
90
90
|
const MONITOR_MODES = membersOf({ monitor: true, whisper: true, barge: true });
|
|
91
91
|
const COMPLETED_BY = membersOf({ agent: true, provider: true });
|
|
92
|
+
const CANCELLED_BY = membersOf({ agent: true, provider: true, party: true });
|
|
92
93
|
const EXPIRABLE_PHASES = membersOf({
|
|
93
94
|
pending: true, confirmed: true, preview: true,
|
|
94
95
|
});
|
|
@@ -598,6 +599,53 @@ const PERSON_SETTABLE = /^(hold|skill:.+)$/;
|
|
|
598
599
|
const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
|
|
599
600
|
/** The level ids in force: the manifest's, or the defaults when the caller holds no manifest. */
|
|
600
601
|
const levelIds = (levels) => levels ?? DEFAULT_LEVEL_IDS;
|
|
602
|
+
/**
|
|
603
|
+
* What the queue locks is locked on the whole task. Given the locked values, every other field an
|
|
604
|
+
* agent reads -- the title, the reference, an attribute, a custom control's label, a browser URL the
|
|
605
|
+
* desk shows -- is held to carrying none of them. A number is compared by its digits, so no
|
|
606
|
+
* formatting hides it; anything else by its text, case aside.
|
|
607
|
+
*/
|
|
608
|
+
function validateNothingLeaksInto(task, locked, path, into) {
|
|
609
|
+
ruleEvaluated("task.locked.leak");
|
|
610
|
+
const carries = (text, value) => {
|
|
611
|
+
if (typeof text !== "string")
|
|
612
|
+
return false;
|
|
613
|
+
const digits = value.replace(/[\s+().-]/g, "");
|
|
614
|
+
if (/^\d+$/.test(digits))
|
|
615
|
+
return text.replace(/\D/g, "").includes(digits);
|
|
616
|
+
return text.toLowerCase().includes(value.trim().toLowerCase());
|
|
617
|
+
};
|
|
618
|
+
const hold = (text, at) => {
|
|
619
|
+
for (const value of locked) {
|
|
620
|
+
if (carries(text, value))
|
|
621
|
+
into.add("task.locked.leak", at, "carries a value the queue locked: what the queue locks is locked on the whole task, and appears nowhere the agent reads");
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
hold(task.title, `${path}.title`);
|
|
625
|
+
hold(task.reference, `${path}.reference`);
|
|
626
|
+
if (Array.isArray(task.attributes)) {
|
|
627
|
+
task.attributes.forEach((attribute, index) => {
|
|
628
|
+
if (!isPlainObject(attribute))
|
|
629
|
+
return;
|
|
630
|
+
hold(attribute.label, `${path}.attributes[${index}].label`);
|
|
631
|
+
hold(attribute.value, `${path}.attributes[${index}].value`);
|
|
632
|
+
if (isPlainObject(attribute.party)) {
|
|
633
|
+
hold(attribute.party.number, `${path}.attributes[${index}].party.number`);
|
|
634
|
+
hold(attribute.party.email, `${path}.attributes[${index}].party.email`);
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
const custom = isPlainObject(task.capabilities) ? task.capabilities.custom : undefined;
|
|
639
|
+
if (Array.isArray(custom))
|
|
640
|
+
custom.forEach((control, index) => { if (isPlainObject(control))
|
|
641
|
+
hold(control.label, `${path}.capabilities.custom[${index}].label`); });
|
|
642
|
+
if (Array.isArray(task.browsers)) {
|
|
643
|
+
task.browsers.forEach((browser, index) => {
|
|
644
|
+
if (isPlainObject(browser) && browser.urlVisibility !== "hidden")
|
|
645
|
+
hold(browser.url, `${path}.browsers[${index}].url`);
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
601
649
|
/** `lockedBy`: a declared level other than `person`, who never locks their own value. */
|
|
602
650
|
function validateLockedByInto(value, rule, path, levels, into) {
|
|
603
651
|
if (!into.filled(value, rule, path, "lockedBy names the level that locked it"))
|
|
@@ -947,6 +995,12 @@ function validateMonitoring(value, channel, path, into) {
|
|
|
947
995
|
into.oneOf(value.mode, MONITOR_MODES, "task.monitoring.mode", `${path}.mode`);
|
|
948
996
|
into.timestamp(value.since, "task.monitoring.since", `${path}.since`);
|
|
949
997
|
}
|
|
998
|
+
/** Whether a task is the agent's own doing: a dial the host placed on its onCall, a lead's join, a lead's monitor. */
|
|
999
|
+
function originatedByTheAgent(task) {
|
|
1000
|
+
if (Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && typeof entry.dialId === "string"))
|
|
1001
|
+
return true;
|
|
1002
|
+
return task.assisting !== undefined || task.monitoring !== undefined;
|
|
1003
|
+
}
|
|
950
1004
|
export function validateTask(task, context, path = "task") {
|
|
951
1005
|
const into = new Collector();
|
|
952
1006
|
validateTaskInto(task, context, path, into);
|
|
@@ -985,15 +1039,27 @@ function validateTaskInto(task, context, path, into) {
|
|
|
985
1039
|
}
|
|
986
1040
|
// Acceptance is the offer's word, carried on the pending task so a snapshot can say it: it
|
|
987
1041
|
// travels exactly when Omni said tasks may be auto-accepted, and only while the task is pending.
|
|
1042
|
+
// Work the agent originated -- a dial or connect-back, carrying the host's dialId on onCall; a
|
|
1043
|
+
// lead's join, carrying assisting; a monitor, carrying monitoring -- was accepted by the command
|
|
1044
|
+
// that created it, and says so whatever the provisioning: the desk shows no Accept for a call
|
|
1045
|
+
// the agent placed. The provisioning governs work the queue routes, and nothing else.
|
|
1046
|
+
const originated = task.phase === "pending" && originatedByTheAgent(task);
|
|
1047
|
+
ruleEvaluated("task.acceptance.originated");
|
|
988
1048
|
if (task.acceptance !== undefined) {
|
|
989
1049
|
into.oneOf(task.acceptance, ACCEPTANCE_MODES, "task.acceptance", `${path}.acceptance`);
|
|
990
1050
|
if (task.phase !== "pending") {
|
|
991
1051
|
into.add("task.acceptance.unexpected", `${path}.acceptance`, "acceptance is an offer's word; a task past pending has been accepted");
|
|
992
1052
|
}
|
|
1053
|
+
else if (originated) {
|
|
1054
|
+
into.require(task.acceptance === "automatic", "task.acceptance.originated", `${path}.acceptance`, "work the agent originated -- a dial, a connect-back, a join, a monitor -- was accepted by the command that created it, and says automatic whatever the provisioning");
|
|
1055
|
+
}
|
|
993
1056
|
else if (context.autoAcceptTasks === false) {
|
|
994
|
-
into.add("task.acceptance.unexpected", `${path}.acceptance`, "autoAcceptTasks is off, so every task requires agent acceptance and a pending task carries no acceptance");
|
|
1057
|
+
into.add("task.acceptance.unexpected", `${path}.acceptance`, "autoAcceptTasks is off, so every task the queue routes requires agent acceptance and a pending task carries no acceptance");
|
|
995
1058
|
}
|
|
996
1059
|
}
|
|
1060
|
+
else if (originated) {
|
|
1061
|
+
into.add("task.acceptance.originated", `${path}.acceptance`, "work the agent originated -- a dial, a connect-back, a join, a monitor -- says acceptance: automatic whatever the provisioning: the command that created it accepted it");
|
|
1062
|
+
}
|
|
997
1063
|
else if (task.phase === "pending" && context.autoAcceptTasks === true) {
|
|
998
1064
|
into.add("task.acceptance.required", `${path}.acceptance`, "autoAcceptTasks is on, so a pending task states how it is accepted");
|
|
999
1065
|
}
|
|
@@ -1015,6 +1081,9 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1015
1081
|
}
|
|
1016
1082
|
if (task.party !== undefined)
|
|
1017
1083
|
validateContactInto(task.party, `${path}.party`, into, context.levels);
|
|
1084
|
+
if (context.locked !== undefined && isPlainObject(task.party) && (isLocked(task.party.number) || isLocked(task.party.email))) {
|
|
1085
|
+
validateNothingLeaksInto(task, context.locked, path, into);
|
|
1086
|
+
}
|
|
1018
1087
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
1019
1088
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
|
|
1020
1089
|
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into, { phase: task.phase, media: task.media });
|
|
@@ -1308,7 +1377,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1308
1377
|
let assisting;
|
|
1309
1378
|
let monitoring;
|
|
1310
1379
|
snapshot.tasks.forEach((task, index) => {
|
|
1311
|
-
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${path}.tasks[${index}]`, into);
|
|
1380
|
+
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
|
|
1312
1381
|
// A lead assists one call at a time, and listens to one at a time.
|
|
1313
1382
|
if (isPlainObject(task) && task.assisting !== undefined) {
|
|
1314
1383
|
if (assisting !== undefined)
|
|
@@ -1419,6 +1488,9 @@ function validateTaskOutcome(value, path, into) {
|
|
|
1419
1488
|
}
|
|
1420
1489
|
break;
|
|
1421
1490
|
case "cancelled":
|
|
1491
|
+
// One word covered the agent declining, the provider withdrawing and the party abandoning the
|
|
1492
|
+
// ring; a record that cannot tell them apart is not a record. by says who, as completed does.
|
|
1493
|
+
into.oneOf(value.by, CANCELLED_BY, "event.taskEnded.outcome.cancelled.by", `${path}.by`);
|
|
1422
1494
|
if (value.reason !== undefined) {
|
|
1423
1495
|
into.filled(value.reason, "event.taskEnded.outcome.cancelled", `${path}.reason`, "a reason must not be empty when present");
|
|
1424
1496
|
}
|
|
@@ -1520,7 +1592,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1520
1592
|
validateBreakState(event.break, `${at}.break`, into);
|
|
1521
1593
|
break;
|
|
1522
1594
|
case "task-offered":
|
|
1523
|
-
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${at}.task`, into);
|
|
1595
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1524
1596
|
// An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
|
|
1525
1597
|
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1526
1598
|
into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
|
|
@@ -1529,7 +1601,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1529
1601
|
into.timestamp(event.allocationExpiresAt, "event.taskOffered.allocationExpiresAt", `${at}.allocationExpiresAt`);
|
|
1530
1602
|
break;
|
|
1531
1603
|
case "task-updated":
|
|
1532
|
-
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${at}.task`, into);
|
|
1604
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1533
1605
|
break;
|
|
1534
1606
|
case "task-media-started":
|
|
1535
1607
|
into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
|
package/guide.md
CHANGED
|
@@ -702,7 +702,7 @@ type AcceptanceMode =
|
|
|
702
702
|
type TaskOutcome =
|
|
703
703
|
| { type: "completed"; by: "agent" | "provider" }
|
|
704
704
|
| { type: "transferred"; destinationId?: string }
|
|
705
|
-
| { type: "cancelled"; reason?: string }
|
|
705
|
+
| { type: "cancelled"; by: "agent" | "provider" | "party"; reason?: string }
|
|
706
706
|
| { type: "expired"; phase: "pending" | "confirmed" | "preview" }
|
|
707
707
|
| { type: "left" }
|
|
708
708
|
| { type: "failed"; failure: ProtocolFailure };
|
|
@@ -831,7 +831,14 @@ lock, so nothing that can be locked — a directory, a number — may carry that
|
|
|
831
831
|
contact's number and email are the same, since each identifies a person: where the queue says
|
|
832
832
|
the agent may not see it, the provider sends `{ lockedBy }` in its place — the last digits or
|
|
833
833
|
nothing — and a CRM link carries a token, never the value with a flag the desk is asked to honour.
|
|
834
|
-
|
|
834
|
+
**What the queue locks is locked on the whole task.** A number the agent may not see appears
|
|
835
|
+
nowhere else they read — not in the title, the reference, an attribute, a custom control's label,
|
|
836
|
+
or a browser URL the desk shows; a browser whose URL must carry it says `urlVisibility: "hidden"`.
|
|
837
|
+
The adapter is the one that knows the value, so it is the one held to it: given the values the
|
|
838
|
+
queue locked, the validator refuses any other field carrying one, digits compared as digits so no
|
|
839
|
+
formatting hides them (`task.locked.leak`), and a conformance run whose tasks lock a party's number
|
|
840
|
+
or email states those values in `lockedValues`, since a run that cannot ask the question is not a
|
|
841
|
+
pass. A host never has the value and never asks. A name is not locked. What the queue provides rather than permits — browsers, dispositions, custom
|
|
835
842
|
controls — is content, and is never locked.
|
|
836
843
|
|
|
837
844
|
**A lead sets the team's policy from their roster.** A login that declares
|
|
@@ -2163,8 +2170,8 @@ person's behalf for want of a word:
|
|
|
2163
2170
|
| `consent` | The provider requires the person's explicit consent: Omni presents **Accept** and waits, whatever its own policy would have done. A host that declares `guarantees.personConsent` promises exactly this; a provider checks it before offering work only a person may take. |
|
|
2164
2171
|
| `automatic` | Omni accepts immediately without agent interaction. |
|
|
2165
2172
|
|
|
2166
|
-
When Omni sent `autoAcceptTasks: false`, the provider omits `acceptance` and every task
|
|
2167
|
-
requires agent acceptance. The two are never confused on the wire: `consent` is always the
|
|
2173
|
+
When Omni sent `autoAcceptTasks: false`, the provider omits `acceptance` and every task the
|
|
2174
|
+
queue routes requires agent acceptance. The two are never confused on the wire: `consent` is always the
|
|
2168
2175
|
provider's requirement, stated on a wire where Omni was willing to accept for the agent; Omni's own
|
|
2169
2176
|
no-auto-accept policy puts no word on the wire at all — the field is absent, and the **Accept**
|
|
2170
2177
|
press is Omni's doing, not the provider's.
|
|
@@ -2178,8 +2185,13 @@ belongs, whatever the host was configured with.
|
|
|
2178
2185
|
|
|
2179
2186
|
An automatically accepted task still arrives through `task-offered`.
|
|
2180
2187
|
|
|
2181
|
-
|
|
2182
|
-
`
|
|
2188
|
+
**Work the agent originated is accepted by the command that created it.** A task born of the
|
|
2189
|
+
agent's own act — a dialpad call or a connect-back, recognisable by the host's `dialId` on
|
|
2190
|
+
`onCall`; a lead's join, carrying `assisting`; a lead's monitor, carrying `monitoring` — arrives
|
|
2191
|
+
through `task-offered` with `acceptance: "automatic"` whatever `autoAcceptTasks` says, and the
|
|
2192
|
+
validator holds it there under either provisioning and under none (`task.acceptance.originated`):
|
|
2193
|
+
the desk shows no **Accept** for a call the agent placed. The provisioning governs work the queue
|
|
2194
|
+
routes to the agent, and nothing else.
|
|
2183
2195
|
|
|
2184
2196
|
### Pending
|
|
2185
2197
|
|
|
@@ -2213,14 +2225,20 @@ on a desk phone — and is not in question per call. See **How the agent hears t
|
|
|
2213
2225
|
Automatic acceptance still begins with `task-offered`.
|
|
2214
2226
|
|
|
2215
2227
|
`allocationExpiresAt` is the deadline after which the offer lapses. Where present, Omni counts
|
|
2216
|
-
down and stops offering **Accept** once it passes
|
|
2228
|
+
down and stops offering **Accept** once it passes; the provider ends the lapsed offer with
|
|
2229
|
+
`task-ended` and an `expired` outcome naming `pending`, since nobody cancelled it. **Omit it unless the provider can observe it.**
|
|
2217
2230
|
A provider that reports only elapsed ring time after the fact cannot say when an offer is due
|
|
2218
2231
|
to end, and a computed value would have Omni withdraw **Accept** from a task still pending.
|
|
2219
2232
|
|
|
2220
2233
|
A preview's deadline is not on the offer. It travels on the task, as `previewEndsAt` with
|
|
2221
2234
|
`atDeadline`, so a snapshot carries it too -- see **Preview: the agent presses Call**.
|
|
2222
2235
|
|
|
2223
|
-
A provider may withdraw a pending task by emitting `task-ended` with a `cancelled` outcome
|
|
2236
|
+
A provider may withdraw a pending task by emitting `task-ended` with a `cancelled` outcome, `by:
|
|
2237
|
+
"provider"`. **`cancelled` says who called the work off**, as `completed` says who completed it:
|
|
2238
|
+
`agent` for a decline, `provider` for a withdrawal or a re-route, `party` for a caller who abandoned
|
|
2239
|
+
the ring. One word for the three left a supervisor's record unable to tell them apart, so `by` is
|
|
2240
|
+
required and closed (`event.taskEnded.outcome.cancelled.by`). An offer nobody acted on before
|
|
2241
|
+
`allocationExpiresAt` is not cancelled by anyone: it lapses, and ends `expired` naming `pending`.
|
|
2224
2242
|
|
|
2225
2243
|
### Tasks already in progress
|
|
2226
2244
|
|
|
@@ -2302,7 +2320,8 @@ The canonical task transitions are:
|
|
|
2302
2320
|
| `preview` | Agent presses Call (`call`) and the customer answers, or the deadline `calls` and they answer | `in-progress` |
|
|
2303
2321
|
| `preview` | The call goes out and nobody answers | `completing` |
|
|
2304
2322
|
| `preview` | The deadline `expires` | Removed by `task-ended` with `expired` outcome |
|
|
2305
|
-
| `pending` | Provider withdraws the allocation | Removed by `task-ended` with `cancelled` outcome |
|
|
2323
|
+
| `pending` | Provider withdraws the allocation, the agent declines, or the party abandons the ring | Removed by `task-ended` with `cancelled` outcome, `by` saying which |
|
|
2324
|
+
| `pending` | The offer lapses at `allocationExpiresAt` | Removed by `task-ended` with `expired` outcome naming `pending` |
|
|
2306
2325
|
| No task | Snapshot reports work already underway | `in-progress` |
|
|
2307
2326
|
| `in-progress` | Provider or agent pauses the task | `paused` |
|
|
2308
2327
|
| `in-progress` | Agent starts a warm transfer (`transfer` `warm`); the customer is parked | `paused` |
|
|
@@ -2413,9 +2432,18 @@ whether the channel carries real-time media:
|
|
|
2413
2432
|
| Channel | Wrap allowance starts at |
|
|
2414
2433
|
| --- | --- |
|
|
2415
2434
|
| Voice and any channel with real-time media | The `task-media-ended` event |
|
|
2416
|
-
| Chat |
|
|
2417
|
-
| Email |
|
|
2418
|
-
| Other non-media channels | The
|
|
2435
|
+
| Chat | The `task-updated` that moves the task to `completing`: the provider's word that the conversation is closed on its side, the allowance running from that publication's `occurredAt` |
|
|
2436
|
+
| Email | The `task-updated` that moves the task to `completing`: the provider's word that the platform has accepted the outgoing message, the allowance running from that publication's `occurredAt` |
|
|
2437
|
+
| Other non-media channels | The `task-updated` that moves the task to `completing` |
|
|
2438
|
+
|
|
2439
|
+
**Off voice, `completing` is the provider's word that handling ended.** There is no media event to
|
|
2440
|
+
carry it, so the phase does; a host starts the wrap clock at that publication and nowhere else. It
|
|
2441
|
+
is optional: a conversation with nothing to wrap moves from `in-progress` to `task-ended` and
|
|
2442
|
+
`completing` is never published. Under `provider-automatic` with a non-zero `wrapAllowance` it is
|
|
2443
|
+
required, because the clock has to start somewhere: a chat or email task the provider completes
|
|
2444
|
+
from `in-progress` with an allowance to run gave the agent none of it, and the stream refuses the
|
|
2445
|
+
ending (`stream.taskEnded.unwrapped`). Under `agent-command` the agent's `complete` is the end,
|
|
2446
|
+
from `in-progress` or from `completing` alike.
|
|
2419
2447
|
|
|
2420
2448
|
```ts
|
|
2421
2449
|
const emailCompletion = {
|
|
@@ -4261,8 +4289,8 @@ beside the mapped one is what the full check costs a host; an adapter has it for
|
|
|
4261
4289
|
validator holds a command only to a task that stands: a task handed in that is not one the wire
|
|
4262
4290
|
published is named (`command.task`) rather than checked against.
|
|
4263
4291
|
|
|
4264
|
-
Declining
|
|
4265
|
-
|
|
4292
|
+
Declining a pending offer ends it without accepting or completing it. The provider confirms the
|
|
4293
|
+
end with `task-ended` and a `cancelled` outcome, `by: "agent"`.
|
|
4266
4294
|
|
|
4267
4295
|
### `execute(request)`
|
|
4268
4296
|
|
|
@@ -4624,7 +4652,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
4624
4652
|
| Function | Validates |
|
|
4625
4653
|
| --- | --- |
|
|
4626
4654
|
| `validateManifest(manifest)` | Identity, protocol-version interoperability, authentication methods, and idle-capability shapes. |
|
|
4627
|
-
| `validateTask(task, { channel })` | Identity, channel agreement, phase, wrap allowance, capability shapes, custom controls, and browsers. |
|
|
4655
|
+
| `validateTask(task, { channel, locked? })` | Identity, channel agreement, phase, wrap allowance, capability shapes, custom controls, and browsers. Given `locked`, the values the queue locked, a task whose party stands locked carries none of them anywhere else (`task.locked.leak`). |
|
|
4628
4656
|
| `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team roster, the stated `taskCount` reconciled against the tasks carried, and every task, contact, and activity, including idle-capability gating both ways: a contribution the manifest never declared is refused, and one it declares is required, `[]` included. |
|
|
4629
4657
|
| `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
|
|
4630
4658
|
| `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
|