@xema/omni-protocol 0.1.31 → 0.1.33
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 +146 -17
- package/dist/index.js +18 -0
- package/dist/testing.d.ts +12 -8
- package/dist/testing.js +72 -16
- package/dist/validation.d.ts +10 -2
- package/dist/validation.js +181 -22
- package/guide.md +279 -79
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -26,6 +26,12 @@ export type IsoTimestamp = string;
|
|
|
26
26
|
export type UserId = string;
|
|
27
27
|
/** A non-empty, opaque task identifier unique within one provider. Scope it with `taskKey()`. */
|
|
28
28
|
export type TaskId = string;
|
|
29
|
+
/**
|
|
30
|
+
* The host's identity for one dial, minted before the command leaves and unique across the
|
|
31
|
+
* installation. A dial is the one command whose outcome arrives later and apart, so the provider
|
|
32
|
+
* names this on the outcome and the host has already placed it. Never shown to the agent.
|
|
33
|
+
*/
|
|
34
|
+
export type DialId = string;
|
|
29
35
|
/** A non-negative whole number of seconds. */
|
|
30
36
|
export type DurationSeconds = number;
|
|
31
37
|
/** New channels require a later protocol version. */
|
|
@@ -68,6 +74,13 @@ export type DialDestinations = "contacts-only" | "any-number";
|
|
|
68
74
|
export interface DialCapability {
|
|
69
75
|
destinations: DialDestinations;
|
|
70
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* How a dial ended, as the switch distinguishes it. `answered` is the one success; the rest are
|
|
79
|
+
* the ways a destination was not reached. A manifest declares which of these its platform can
|
|
80
|
+
* tell apart (`dialOutcomes`), and a `dial-outcome` carries only a declared one.
|
|
81
|
+
*/
|
|
82
|
+
export type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "rejected";
|
|
83
|
+
export declare const DIAL_OUTCOMES: readonly ["answered", "busy", "no-answer", "unreachable", "rejected"];
|
|
71
84
|
/** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
|
|
72
85
|
export declare const IDLE_CAPABILITIES: readonly ["dial", "personalBrowser", "calendar", "contacts"];
|
|
73
86
|
export type IdleCapability = (typeof IDLE_CAPABILITIES)[number];
|
|
@@ -113,6 +126,18 @@ export interface Manifest<C extends Channel = Channel> {
|
|
|
113
126
|
taskTypePresentation?: Record<string, TaskTypePresentation>;
|
|
114
127
|
/** The organisation's whole ladder, stated outright, `person` included. Omitted for the typical four, `DEFAULT_LEVELS`. */
|
|
115
128
|
orgLevels?: LevelDeclaration[];
|
|
129
|
+
/**
|
|
130
|
+
* The outcomes this platform distinguishes when a dial ends, `answered` and at least one way of
|
|
131
|
+
* not reaching the destination. Required of a provider that dials at all -- an idle dialpad, or
|
|
132
|
+
* a task that may transfer, conference, or call back -- and forbidden off voice, where nothing dials.
|
|
133
|
+
*/
|
|
134
|
+
dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
|
|
135
|
+
/**
|
|
136
|
+
* The provider takes running reports of a host-performed step -- `recordStep` with `seconds`
|
|
137
|
+
* so far and no `ended`. Absent, the host sends exactly two reports per leg, when it began and
|
|
138
|
+
* when it ended, and a running report is refused: what a provider never asked for never crosses.
|
|
139
|
+
*/
|
|
140
|
+
runningStepReports?: true;
|
|
116
141
|
}
|
|
117
142
|
export interface SecretStore {
|
|
118
143
|
get(key: string): Promise<string | undefined>;
|
|
@@ -493,9 +518,33 @@ export type TaskAttribute = TaskAttributeBase & ({
|
|
|
493
518
|
at: IsoTimestamp;
|
|
494
519
|
});
|
|
495
520
|
export type HandlingStep = "queued" | "offered" | "answered" | "held" | "muted" | "transferred" | "conferenced" | "unanswered";
|
|
521
|
+
/**
|
|
522
|
+
* The call record: the steps that brought the task here, one entry per occurrence and oldest
|
|
523
|
+
* first, and what they add up to before this agent -- stated by the provider from its own record,
|
|
524
|
+
* never summed by a desk from instants. Each total is present when the provider knows it and
|
|
525
|
+
* absent when it does not; a plausible nought is the fallback the no-fallbacks rule forbids. The
|
|
526
|
+
* record rides on the task and is replaced with it, so a late entry corrects the sums.
|
|
527
|
+
*/
|
|
528
|
+
export interface TaskHandlingHistory {
|
|
529
|
+
steps: TaskHandlingStep[];
|
|
530
|
+
/** Seconds others spent handling it before this agent. */
|
|
531
|
+
handleSeconds?: DurationSeconds;
|
|
532
|
+
/** Seconds the caller spent on hold at others' hands. */
|
|
533
|
+
holdSeconds?: DurationSeconds;
|
|
534
|
+
/** Seconds waiting before anyone answered. */
|
|
535
|
+
queueSeconds?: DurationSeconds;
|
|
536
|
+
/** How many times the task changed hands. */
|
|
537
|
+
transfers?: number;
|
|
538
|
+
}
|
|
496
539
|
export interface TaskHandlingStep {
|
|
497
540
|
step: HandlingStep;
|
|
498
541
|
at: IsoTimestamp;
|
|
542
|
+
/**
|
|
543
|
+
* On a step that dialled -- `transferred`, `conferenced`, `unanswered` -- the host's identity for
|
|
544
|
+
* that dial when a host placed it, and the address it went to. Neither belongs on any other step.
|
|
545
|
+
*/
|
|
546
|
+
dialId?: DialId;
|
|
547
|
+
destination?: string;
|
|
499
548
|
/**
|
|
500
549
|
* Reported, never derived. An entry may be written while its leg is still running, so there is
|
|
501
550
|
* no end to subtract from -- and omitted is the honest report of that, where nought would
|
|
@@ -523,16 +572,29 @@ export type TaskCompletion = {
|
|
|
523
572
|
completionMode: "provider-automatic";
|
|
524
573
|
wrapAllowance: DurationSeconds;
|
|
525
574
|
};
|
|
575
|
+
export type OnCallRole = "party" | "agent" | "consulted" | "conferenced";
|
|
576
|
+
export declare const ON_CALL_ROLES: readonly ["party", "agent", "consulted", "conferenced"];
|
|
526
577
|
/**
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
578
|
+
* Somebody on the call right now, as the provider states it: who, since when, and whether they
|
|
579
|
+
* are held aside. `party` is the customer; `agent` a person, by user id; `consulted` and
|
|
580
|
+
* `conferenced` someone a dial brought in, carrying the dial when a host placed it and the address
|
|
581
|
+
* either way. `label` names a destination -- a person, a queue -- not a phrase. At most one
|
|
582
|
+
* `party` and one `consulted`: the consult commands name neither because there is exactly one.
|
|
530
583
|
*/
|
|
531
|
-
export
|
|
584
|
+
export type OnCall = {
|
|
585
|
+
since: IsoTimestamp;
|
|
586
|
+
held?: true;
|
|
587
|
+
} & ({
|
|
588
|
+
role: "party";
|
|
589
|
+
} | {
|
|
590
|
+
role: "agent";
|
|
591
|
+
userId: UserId;
|
|
592
|
+
} | {
|
|
593
|
+
role: "consulted" | "conferenced";
|
|
532
594
|
destination: string;
|
|
595
|
+
dialId?: DialId;
|
|
533
596
|
label?: string;
|
|
534
|
-
|
|
535
|
-
}
|
|
597
|
+
});
|
|
536
598
|
/**
|
|
537
599
|
* The agent's request for a lead, from asking until the lead leaves or the request ends.
|
|
538
600
|
* `requested` while nobody has joined; `joined`, with `leadId`, once somebody has.
|
|
@@ -617,14 +679,14 @@ export type Task<C extends Channel = Channel> = {
|
|
|
617
679
|
/** The identifier an agent reads back to a customer, where the provider has one. */
|
|
618
680
|
reference?: string;
|
|
619
681
|
attributes?: TaskAttribute[];
|
|
620
|
-
handlingHistory?:
|
|
682
|
+
handlingHistory?: TaskHandlingHistory;
|
|
621
683
|
} & TaskCompletion & (C extends "voice" ? {
|
|
622
|
-
|
|
684
|
+
onCall?: OnCall[];
|
|
623
685
|
lead?: TaskLead;
|
|
624
686
|
assisting?: TaskAssisting;
|
|
625
687
|
media?: TaskMediaState;
|
|
626
688
|
} : {
|
|
627
|
-
|
|
689
|
+
onCall?: never;
|
|
628
690
|
lead?: never;
|
|
629
691
|
assisting?: never;
|
|
630
692
|
media?: never;
|
|
@@ -684,13 +746,15 @@ export type VoiceTaskCommand = {
|
|
|
684
746
|
} | {
|
|
685
747
|
type: "disconnect";
|
|
686
748
|
}
|
|
687
|
-
/** Issuable only in `completing`, under the `callback` capability.
|
|
749
|
+
/** Issuable only in `completing`, under the `callback` capability. Dials the party's own number, so it names none. */
|
|
688
750
|
| {
|
|
689
751
|
type: "callback";
|
|
752
|
+
dialId: DialId;
|
|
690
753
|
}
|
|
691
754
|
/** Blind: hand the customer to `destination` with nobody consulted. Gated by `blindTransfer`. */
|
|
692
755
|
| {
|
|
693
756
|
type: "transfer";
|
|
757
|
+
dialId: DialId;
|
|
694
758
|
destination: string;
|
|
695
759
|
action?: never;
|
|
696
760
|
}
|
|
@@ -698,14 +762,15 @@ export type VoiceTaskCommand = {
|
|
|
698
762
|
| {
|
|
699
763
|
type: "transfer";
|
|
700
764
|
action: "consult";
|
|
765
|
+
dialId: DialId;
|
|
701
766
|
destination: string;
|
|
702
767
|
}
|
|
703
|
-
/** Hand the customer to the consulted destination and leave. Needs `Task.
|
|
768
|
+
/** Hand the customer to the consulted destination and leave. Needs a `consulted` entry on `Task.onCall`. */
|
|
704
769
|
| {
|
|
705
770
|
type: "transfer";
|
|
706
771
|
action: "complete";
|
|
707
772
|
}
|
|
708
|
-
/** Drop the consulted destination and return to the customer. Needs `Task.
|
|
773
|
+
/** Drop the consulted destination and return to the customer. Needs a `consulted` entry on `Task.onCall`. */
|
|
709
774
|
| {
|
|
710
775
|
type: "transfer";
|
|
711
776
|
action: "cancel";
|
|
@@ -730,10 +795,19 @@ export type VoiceTaskCommand = {
|
|
|
730
795
|
| {
|
|
731
796
|
type: "lead";
|
|
732
797
|
action: "leave";
|
|
733
|
-
}
|
|
798
|
+
}
|
|
799
|
+
/** Dial `destination` into the call. Gated by `conference`. */
|
|
800
|
+
| {
|
|
801
|
+
type: "conference";
|
|
802
|
+
action: "add";
|
|
803
|
+
dialId: DialId;
|
|
804
|
+
destination: string;
|
|
805
|
+
}
|
|
806
|
+
/** Drop the `conferenced` entry on `Task.onCall` with this destination. */
|
|
807
|
+
| {
|
|
734
808
|
type: "conference";
|
|
735
|
-
|
|
736
|
-
|
|
809
|
+
action: "remove";
|
|
810
|
+
destination: string;
|
|
737
811
|
} | {
|
|
738
812
|
type: "recording";
|
|
739
813
|
action: "start" | "pause" | "resume" | "stop";
|
|
@@ -771,22 +845,33 @@ export interface TaskCommandRequest<C extends Channel = Channel> {
|
|
|
771
845
|
/**
|
|
772
846
|
* `applied` rather than a verb per command: the command travels in the request, so
|
|
773
847
|
* `execute({ command: { type: "hold" } })` returning `applied` already says the hold applied.
|
|
848
|
+
* A command that dials answers `dialling` instead, restating the host's `dialId`: accepted, the
|
|
849
|
+
* call being placed, and the outcome still to come on `dial-outcome`.
|
|
774
850
|
*
|
|
775
851
|
* A promise that rejects with no result means *unknown*, not `failed`. Omni does not retry --
|
|
776
852
|
* no adapter on the agent's PC can make a repeat safe -- and the next snapshot shows what the
|
|
777
|
-
* provider did. A command therefore carries no key of Omni's making
|
|
853
|
+
* provider did. A command therefore carries no retry key of Omni's making; a `dialId` is not
|
|
854
|
+
* one, it is how an outcome arriving later is placed.
|
|
778
855
|
*/
|
|
779
856
|
export type TaskCommandResult = {
|
|
780
857
|
status: "applied";
|
|
858
|
+
} | {
|
|
859
|
+
status: "dialling";
|
|
860
|
+
dialId: DialId;
|
|
781
861
|
} | {
|
|
782
862
|
status: "failed";
|
|
783
863
|
failure: ProtocolFailure;
|
|
784
864
|
};
|
|
865
|
+
/** The dial a command places, by the host's identity for it; `undefined` for a command that dials nothing. */
|
|
866
|
+
export declare function commandDialId(command: TaskCommand): DialId | undefined;
|
|
785
867
|
export interface DialRequest {
|
|
868
|
+
dialId: DialId;
|
|
786
869
|
destination: string;
|
|
787
870
|
}
|
|
871
|
+
/** `dialling` says accepted and being placed, nothing more; the outcome arrives on `dial-outcome`. */
|
|
788
872
|
export type DialResult = {
|
|
789
|
-
status: "
|
|
873
|
+
status: "dialling";
|
|
874
|
+
dialId: DialId;
|
|
790
875
|
} | {
|
|
791
876
|
status: "failed";
|
|
792
877
|
failure: ProtocolFailure;
|
|
@@ -1025,6 +1110,31 @@ export type SetPreferenceRequest = {
|
|
|
1025
1110
|
id: PreferenceId;
|
|
1026
1111
|
inherit: true;
|
|
1027
1112
|
};
|
|
1113
|
+
/**
|
|
1114
|
+
* The host's report of a handling leg it performed itself -- a mute, which Omni does rather than
|
|
1115
|
+
* the provider -- so the provider's record has an account of it. Keyed by `step` and `at`: the
|
|
1116
|
+
* same entry is reported when it begins, as often as the host cares to while it runs, and once
|
|
1117
|
+
* more with `ended`, when `seconds` is the final duration. The host is the authority for the
|
|
1118
|
+
* legs it performs, so `seconds` may say how long so far at any time; the end is stated, never
|
|
1119
|
+
* inferred from a number's presence. What the adapter forwards upstream, and how often, is its own.
|
|
1120
|
+
*/
|
|
1121
|
+
export type HandlingReport = {
|
|
1122
|
+
taskId: TaskId;
|
|
1123
|
+
step: HandlingStep;
|
|
1124
|
+
at: IsoTimestamp;
|
|
1125
|
+
} & ({
|
|
1126
|
+
ended: true;
|
|
1127
|
+
seconds: DurationSeconds;
|
|
1128
|
+
} | {
|
|
1129
|
+
ended?: never;
|
|
1130
|
+
seconds?: DurationSeconds;
|
|
1131
|
+
});
|
|
1132
|
+
export type HandlingReportResult = {
|
|
1133
|
+
status: "recorded";
|
|
1134
|
+
} | {
|
|
1135
|
+
status: "failed";
|
|
1136
|
+
failure: ProtocolFailure;
|
|
1137
|
+
};
|
|
1028
1138
|
export type PreferenceResult = {
|
|
1029
1139
|
status: "applied";
|
|
1030
1140
|
} | {
|
|
@@ -1099,6 +1209,20 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1099
1209
|
type: "task-ended";
|
|
1100
1210
|
taskId: TaskId;
|
|
1101
1211
|
outcome: TaskOutcome;
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* How a dial the host placed ended, once, either way -- `answered` is stated, never read off
|
|
1215
|
+
* somebody appearing on the call. `taskId` where the dial was on a task, and that task may
|
|
1216
|
+
* already have ended: a dial placed late routinely outlives its call. `reason` is the switch's
|
|
1217
|
+
* own words, shown to the agent as such.
|
|
1218
|
+
*/
|
|
1219
|
+
| {
|
|
1220
|
+
type: "dial-outcome";
|
|
1221
|
+
dialId: DialId;
|
|
1222
|
+
outcome: DialOutcome;
|
|
1223
|
+
taskId?: TaskId;
|
|
1224
|
+
destination?: string;
|
|
1225
|
+
reason?: string;
|
|
1102
1226
|
} | {
|
|
1103
1227
|
type: "announcement";
|
|
1104
1228
|
text: string;
|
|
@@ -1168,6 +1292,8 @@ export interface Connection<C extends Channel = Channel> {
|
|
|
1168
1292
|
openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
|
|
1169
1293
|
/** Required when the login declares `capabilities.preferences`: the person's own choice, kept by the provider and republished as `authenticated`. */
|
|
1170
1294
|
setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
|
|
1295
|
+
/** Records a handling leg the host performed. Required of a connection whose tasks may declare `mute`. */
|
|
1296
|
+
recordStep?(report: HandlingReport): Promise<HandlingReportResult>;
|
|
1171
1297
|
}
|
|
1172
1298
|
export interface Adapter<C extends Channel = Channel> {
|
|
1173
1299
|
manifest: Manifest<C>;
|
|
@@ -1240,6 +1366,9 @@ export declare const taskKey: (providerId: string, taskId: TaskId) => string;
|
|
|
1240
1366
|
export declare const userKey: (providerId: string, userId: UserId) => string;
|
|
1241
1367
|
/** Every handling step somebody takes part in. `queued` is the one nobody does. */
|
|
1242
1368
|
export declare const HANDLING_STEPS_WITH_A_PERSON: readonly ["offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered"];
|
|
1369
|
+
/** The steps a dial writes, and so the only ones that carry a `dialId` and a `destination`. */
|
|
1370
|
+
export declare const HANDLING_STEPS_THAT_DIAL: readonly ["transferred", "conferenced", "unanswered"];
|
|
1371
|
+
export declare const handlingStepDials: (step: HandlingStep) => boolean;
|
|
1243
1372
|
/** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
|
|
1244
1373
|
export declare function handlingStepExpectsAPerson(step: HandlingStep): boolean;
|
|
1245
1374
|
export interface BrowserSessionKeyInput {
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export function negotiateProtocolVersion(adapterVersions, hostVersions = OMNI_SU
|
|
|
15
15
|
const shared = adapterVersions.filter(version => host.has(version));
|
|
16
16
|
return shared.length === 0 ? undefined : Math.max(...shared);
|
|
17
17
|
}
|
|
18
|
+
export const DIAL_OUTCOMES = ["answered", "busy", "no-answer", "unreachable", "rejected"];
|
|
18
19
|
/** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
|
|
19
20
|
export const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"];
|
|
20
21
|
/** What Omni calls each idle capability. */
|
|
@@ -50,6 +51,7 @@ export function isAllowedBrowserUrl(url) {
|
|
|
50
51
|
return false;
|
|
51
52
|
}
|
|
52
53
|
}
|
|
54
|
+
export const ON_CALL_ROLES = ["party", "agent", "consulted", "conferenced"];
|
|
53
55
|
/**
|
|
54
56
|
* The levels a typical organisation has: the ladder in force when a manifest declares no
|
|
55
57
|
* `orgLevels`. A manifest that declares any states its whole ladder outright.
|
|
@@ -73,6 +75,16 @@ export const TASK_COMMAND_NAMES = {
|
|
|
73
75
|
chat: ["accept", "reject", "pause", "resume", "complete"],
|
|
74
76
|
email: ["accept", "reject", "complete"],
|
|
75
77
|
};
|
|
78
|
+
/** The dial a command places, by the host's identity for it; `undefined` for a command that dials nothing. */
|
|
79
|
+
export function commandDialId(command) {
|
|
80
|
+
if (command.type === "callback")
|
|
81
|
+
return command.dialId;
|
|
82
|
+
if (command.type === "transfer" && (command.action === undefined || command.action === "consult"))
|
|
83
|
+
return command.dialId;
|
|
84
|
+
if (command.type === "conference" && command.action === "add")
|
|
85
|
+
return command.dialId;
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
76
88
|
export const BREAK_KINDS = [
|
|
77
89
|
"short-break", "meal", "rest", "training", "coaching",
|
|
78
90
|
"meeting", "administrative", "technical", "personal", "other",
|
|
@@ -138,6 +150,12 @@ export const userKey = (providerId, userId) => `${encodeURIComponent(providerId)
|
|
|
138
150
|
export const HANDLING_STEPS_WITH_A_PERSON = [
|
|
139
151
|
"offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered",
|
|
140
152
|
];
|
|
153
|
+
/** The steps a dial writes, and so the only ones that carry a `dialId` and a `destination`. */
|
|
154
|
+
export const HANDLING_STEPS_THAT_DIAL = ["transferred", "conferenced", "unanswered"];
|
|
155
|
+
export const handlingStepDials = (step) => HANDLING_STEPS_THAT_DIAL.includes(step);
|
|
156
|
+
// Pinned both ways: a step added to `HandlingStep` has to be placed here, and a step listed here
|
|
157
|
+
// has to exist there. `satisfies` on the list covers the second; this statement covers the first.
|
|
158
|
+
true;
|
|
141
159
|
/** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
|
|
142
160
|
export function handlingStepExpectsAPerson(step) {
|
|
143
161
|
return HANDLING_STEPS_WITH_A_PERSON.includes(step);
|
package/dist/testing.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation }
|
|
|
7
7
|
* rules -- each optional part of a task, each optional part of the break state and roster, each
|
|
8
8
|
* declared contribution, and each event type.
|
|
9
9
|
*/
|
|
10
|
-
declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.
|
|
10
|
+
declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.onCall", "task.lead", "task.assisting", "task.media", "task.acceptance", "task.dispositions", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities", "team.policies"];
|
|
11
11
|
export type ContractSubject = (typeof STATE_SUBJECTS)[number] | `event.${ProviderEvent["type"]}`;
|
|
12
12
|
export interface AdapterContractResult {
|
|
13
13
|
events: ProviderEventEnvelope[];
|
|
@@ -98,6 +98,10 @@ export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApp
|
|
|
98
98
|
/** What a stream has said about the tasks it carries, and the rules across events. */
|
|
99
99
|
export declare class TaskStream {
|
|
100
100
|
private readonly tasks;
|
|
101
|
+
private readonly dials;
|
|
102
|
+
/** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
|
|
103
|
+
dialled(dialId: string): void;
|
|
104
|
+
private noteDials;
|
|
101
105
|
private static stated;
|
|
102
106
|
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
103
107
|
seed(snapshot: unknown): void;
|
|
@@ -127,7 +131,7 @@ export declare function assertBreakFollowsItsRequests(envelopes: readonly Provid
|
|
|
127
131
|
export declare function assertMediaFollowsTheTask(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
|
|
128
132
|
/** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
|
|
129
133
|
export declare function stillHost(report?: HostReport, guarantees?: HostGuarantees): Host;
|
|
130
|
-
/** One provider as the host sees it when freezing a break attempt
|
|
134
|
+
/** One provider as the host sees it when freezing the providers a break attempt asks. */
|
|
131
135
|
export interface BreakCandidate {
|
|
132
136
|
id: string;
|
|
133
137
|
authentication: AuthenticationState["status"];
|
|
@@ -135,13 +139,13 @@ export interface BreakCandidate {
|
|
|
135
139
|
holdsCapacity: boolean;
|
|
136
140
|
}
|
|
137
141
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
142
|
+
* A break attempt asks every connected provider from which the agent can currently receive work.
|
|
143
|
+
* A provider whose login is not usable -- `expired` above all -- is not asked, whatever else is
|
|
144
|
+
* true of it: nothing can be asked of it, and a host that waits on it stalls the break for
|
|
145
|
+
* everyone. A usable provider holding capacity is asked, and cannot be left out. `refreshing` is
|
|
146
|
+
* usable: identity and capabilities remain available and work continues.
|
|
143
147
|
*/
|
|
144
|
-
export declare function
|
|
148
|
+
export declare function assertBreakAttemptProviders(candidates: readonly BreakCandidate[], asked: readonly string[]): void;
|
|
145
149
|
/** One published moment of a break asked for on a task: the approval, and how many tasks were outstanding. */
|
|
146
150
|
export interface BreakOnTaskStep {
|
|
147
151
|
approval: BreakApproval;
|
package/dist/testing.js
CHANGED
|
@@ -12,7 +12,7 @@ const STATE_SUBJECTS = [
|
|
|
12
12
|
"task.browsers",
|
|
13
13
|
"task.attributes",
|
|
14
14
|
"task.handlingHistory",
|
|
15
|
-
"task.
|
|
15
|
+
"task.onCall",
|
|
16
16
|
"task.lead",
|
|
17
17
|
"task.assisting",
|
|
18
18
|
"task.media",
|
|
@@ -33,7 +33,7 @@ const STATE_SUBJECTS = [
|
|
|
33
33
|
// `ProviderEvent` without a row here, or a row it lacks, is a compile error.
|
|
34
34
|
const EVENT_TYPES = {
|
|
35
35
|
snapshot: true, "transport-status": true, "break-state": true, "task-offered": true, "task-updated": true,
|
|
36
|
-
"task-media-started": true, "task-media-ended": true, "task-ended": true, announcement: true, "queue-summary": true, diagnostic: true,
|
|
36
|
+
"task-media-started": true, "task-media-ended": true, "task-ended": true, "dial-outcome": true, announcement: true, "queue-summary": true, diagnostic: true,
|
|
37
37
|
"team-updated": true, "contacts-updated": true, "calendar-updated": true,
|
|
38
38
|
};
|
|
39
39
|
const CONTRACT_SUBJECTS = [
|
|
@@ -52,10 +52,10 @@ function observeTask(value, seen) {
|
|
|
52
52
|
seen.add("task.browsers");
|
|
53
53
|
if (some(value.attributes))
|
|
54
54
|
seen.add("task.attributes");
|
|
55
|
-
if (some(value.handlingHistory))
|
|
55
|
+
if (isRecord(value.handlingHistory) && some(value.handlingHistory.steps))
|
|
56
56
|
seen.add("task.handlingHistory");
|
|
57
|
-
if (value.
|
|
58
|
-
seen.add("task.
|
|
57
|
+
if (some(value.onCall))
|
|
58
|
+
seen.add("task.onCall");
|
|
59
59
|
if (value.lead !== undefined)
|
|
60
60
|
seen.add("task.lead");
|
|
61
61
|
if (value.assisting !== undefined)
|
|
@@ -289,6 +289,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
289
289
|
}
|
|
290
290
|
if (eventNamesUsers(envelope))
|
|
291
291
|
requireMethod(live, "describeUsers", "an event publishes a UserId");
|
|
292
|
+
if (eventDeclaresMute(envelope))
|
|
293
|
+
requireMethod(live, "recordStep", "a task declares mute, which the host performs and must have somewhere to record");
|
|
292
294
|
// Cross-event rules apply once the stream has a beginning: the connect snapshot.
|
|
293
295
|
if (seeded)
|
|
294
296
|
violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
|
|
@@ -308,6 +310,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
308
310
|
requireCapabilityMethods(live, current().capabilities);
|
|
309
311
|
if (publishesUserIds(snapshot))
|
|
310
312
|
requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
|
|
313
|
+
if (isRecord(snapshot) && Array.isArray(snapshot.tasks) && snapshot.tasks.some(taskDeclaresMute)) {
|
|
314
|
+
requireMethod(live, "recordStep", "a task declares mute, which the host performs and must have somewhere to record");
|
|
315
|
+
}
|
|
311
316
|
// Capacity is stated, not requested: nothing may be allocated until it is, so a connection
|
|
312
317
|
// that will not accept one is a connection nothing can be given to.
|
|
313
318
|
// The guide's obligation on a voice adapter: consult the host before declaring the agent
|
|
@@ -398,9 +403,21 @@ function publishesUserIds(snapshot) {
|
|
|
398
403
|
return snapshot.tasks.some(taskNamesUsers);
|
|
399
404
|
}
|
|
400
405
|
const teamNamesUsers = (team) => isRecord(team) && (some(team.members) || some(team.requests));
|
|
401
|
-
const taskNamesUsers = (task) => isRecord(task) && ((Array.isArray(task.handlingHistory) && task.handlingHistory.some(step => isRecord(step) && step.by !== undefined)) ||
|
|
406
|
+
const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.handlingHistory) && Array.isArray(task.handlingHistory.steps) && task.handlingHistory.steps.some(step => isRecord(step) && step.by !== undefined)) ||
|
|
402
407
|
(isRecord(task.lead) && task.lead.leadId !== undefined) ||
|
|
403
408
|
isRecord(task.assisting));
|
|
409
|
+
/** Mute is the leg the host performs, so a task that allows it obliges the provider to take the record. */
|
|
410
|
+
const taskDeclaresMute = (task) => isRecord(task) && isRecord(task.capabilities) && task.capabilities.mute !== undefined;
|
|
411
|
+
function eventDeclaresMute(envelope) {
|
|
412
|
+
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
413
|
+
if (!isRecord(event))
|
|
414
|
+
return false;
|
|
415
|
+
if (event.type === "task-offered" || event.type === "task-updated")
|
|
416
|
+
return taskDeclaresMute(event.task);
|
|
417
|
+
if (event.type === "snapshot" && isRecord(event.snapshot) && Array.isArray(event.snapshot.tasks))
|
|
418
|
+
return event.snapshot.tasks.some(taskDeclaresMute);
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
404
421
|
/** Whether an event publishes a `UserId`, on a roster, a task, or the snapshot a reconnect carries. */
|
|
405
422
|
function eventNamesUsers(envelope) {
|
|
406
423
|
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
@@ -600,6 +617,27 @@ const WORK_BEGUN = new Set(["in-progress", "paused", "completing"]);
|
|
|
600
617
|
/** What a stream has said about the tasks it carries, and the rules across events. */
|
|
601
618
|
export class TaskStream {
|
|
602
619
|
tasks = new Map();
|
|
620
|
+
// Every dial the stream can place an outcome against: one the host said it placed, or one a
|
|
621
|
+
// task carried on `onCall` or in its record -- which is how a dial made before a transfer is known
|
|
622
|
+
// to whoever holds the task now. `ended` once its outcome arrived, since an outcome comes once.
|
|
623
|
+
dials = new Map();
|
|
624
|
+
/** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
|
|
625
|
+
dialled(dialId) {
|
|
626
|
+
if (!this.dials.has(dialId))
|
|
627
|
+
this.dials.set(dialId, "placed");
|
|
628
|
+
}
|
|
629
|
+
noteDials(task) {
|
|
630
|
+
if (!isRecord(task))
|
|
631
|
+
return;
|
|
632
|
+
const entries = [
|
|
633
|
+
...(Array.isArray(task.onCall) ? task.onCall : []),
|
|
634
|
+
...(isRecord(task.handlingHistory) && Array.isArray(task.handlingHistory.steps) ? task.handlingHistory.steps : []),
|
|
635
|
+
];
|
|
636
|
+
for (const entry of entries) {
|
|
637
|
+
if (isRecord(entry) && typeof entry.dialId === "string")
|
|
638
|
+
this.dialled(entry.dialId);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
603
641
|
static stated(task) {
|
|
604
642
|
const media = isRecord(task) && (task.media === "started" || task.media === "ended") ? task.media : "none";
|
|
605
643
|
return { phase: String(isRecord(task) ? task.phase : undefined), media };
|
|
@@ -612,6 +650,7 @@ export class TaskStream {
|
|
|
612
650
|
for (const task of snapshot.tasks) {
|
|
613
651
|
if (isRecord(task) && typeof task.id === "string")
|
|
614
652
|
this.tasks.set(task.id, TaskStream.stated(task));
|
|
653
|
+
this.noteDials(task);
|
|
615
654
|
}
|
|
616
655
|
}
|
|
617
656
|
/** Applies one envelope and returns what it may not say given what came before. */
|
|
@@ -634,6 +673,7 @@ export class TaskStream {
|
|
|
634
673
|
if (known !== undefined)
|
|
635
674
|
refuse("stream.taskOffered.duplicate", `${at}.task.id`, `${id} is already on the stream; an offer introduces a task once`);
|
|
636
675
|
this.tasks.set(id, TaskStream.stated(event.task));
|
|
676
|
+
this.noteDials(event.task);
|
|
637
677
|
break;
|
|
638
678
|
case "task-updated":
|
|
639
679
|
if (id === undefined)
|
|
@@ -658,6 +698,7 @@ export class TaskStream {
|
|
|
658
698
|
}
|
|
659
699
|
this.tasks.set(id, next);
|
|
660
700
|
}
|
|
701
|
+
this.noteDials(event.task);
|
|
661
702
|
break;
|
|
662
703
|
case "task-media-started":
|
|
663
704
|
if (id === undefined)
|
|
@@ -696,6 +737,21 @@ export class TaskStream {
|
|
|
696
737
|
refuse("stream.taskEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
697
738
|
this.tasks.delete(id);
|
|
698
739
|
break;
|
|
740
|
+
case "dial-outcome": {
|
|
741
|
+
// An outcome ends a dial somebody placed, once. The task it names may already have ended;
|
|
742
|
+
// a dial placed late routinely outlives its call, which is why the dial has its own identity.
|
|
743
|
+
if (typeof event.dialId !== "string")
|
|
744
|
+
break;
|
|
745
|
+
const dial = this.dials.get(event.dialId);
|
|
746
|
+
if (dial === undefined) {
|
|
747
|
+
refuse("stream.dialOutcome.unknown", `${at}.dialId`, `${event.dialId} is not a dial the host placed or a task carried: an outcome ends a dial somebody made`);
|
|
748
|
+
}
|
|
749
|
+
else if (dial === "ended") {
|
|
750
|
+
refuse("stream.dialOutcome.duplicate", `${at}.dialId`, `${event.dialId} already has its outcome; a dial ends once`);
|
|
751
|
+
}
|
|
752
|
+
this.dials.set(event.dialId, "ended");
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
699
755
|
default:
|
|
700
756
|
break;
|
|
701
757
|
}
|
|
@@ -785,26 +841,26 @@ export function stillHost(report = { online: true }, guarantees = {}) {
|
|
|
785
841
|
}
|
|
786
842
|
const usableLogin = (status) => status === "authenticated" || status === "refreshing";
|
|
787
843
|
/**
|
|
788
|
-
*
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
844
|
+
* A break attempt asks every connected provider from which the agent can currently receive work.
|
|
845
|
+
* A provider whose login is not usable -- `expired` above all -- is not asked, whatever else is
|
|
846
|
+
* true of it: nothing can be asked of it, and a host that waits on it stalls the break for
|
|
847
|
+
* everyone. A usable provider holding capacity is asked, and cannot be left out. `refreshing` is
|
|
848
|
+
* usable: identity and capabilities remain available and work continues.
|
|
793
849
|
*/
|
|
794
|
-
export function
|
|
795
|
-
const chosen = new Set(
|
|
796
|
-
for (const id of
|
|
850
|
+
export function assertBreakAttemptProviders(candidates, asked) {
|
|
851
|
+
const chosen = new Set(asked);
|
|
852
|
+
for (const id of asked) {
|
|
797
853
|
if (!candidates.some(candidate => candidate.id === id))
|
|
798
854
|
throw new Error(`${id} is not a provider the host knows`);
|
|
799
855
|
}
|
|
800
856
|
for (const candidate of candidates) {
|
|
801
857
|
const expected = usableLogin(candidate.authentication) && candidate.holdsCapacity;
|
|
802
858
|
if (expected && !chosen.has(candidate.id)) {
|
|
803
|
-
throw new Error(`${candidate.id} can give the agent work and must be
|
|
859
|
+
throw new Error(`${candidate.id} can give the agent work and must be asked`);
|
|
804
860
|
}
|
|
805
861
|
if (!expected && chosen.has(candidate.id)) {
|
|
806
862
|
const why = usableLogin(candidate.authentication) ? "holds no capacity" : `is ${candidate.authentication}`;
|
|
807
|
-
throw new Error(`${candidate.id} ${why} and is not
|
|
863
|
+
throw new Error(`${candidate.id} ${why} and is not asked: nothing can be asked of it`);
|
|
808
864
|
}
|
|
809
865
|
}
|
|
810
866
|
}
|
package/dist/validation.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface TaskValidationContext {
|
|
|
17
17
|
levels?: readonly string[];
|
|
18
18
|
/** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether a pending task states its `acceptance`. */
|
|
19
19
|
autoAcceptTasks?: boolean;
|
|
20
|
+
/** Whether the manifest declares `dialOutcomes`. A task that may dial needs it to; absent, the question is not asked. */
|
|
21
|
+
dialOutcomesDeclared?: boolean;
|
|
20
22
|
}
|
|
21
23
|
export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
|
|
22
24
|
/**
|
|
@@ -49,16 +51,22 @@ export declare function validateEventEnvelope(envelope: unknown, manifest: unkno
|
|
|
49
51
|
* promise withheld is an absent key, never a false one, exactly as a capability is.
|
|
50
52
|
*/
|
|
51
53
|
export declare function validateHostGuarantees(guarantees: unknown, path?: string): ProtocolViolation[];
|
|
54
|
+
/**
|
|
55
|
+
* What the host reports of a leg it performed, as an adapter may validate it before forwarding:
|
|
56
|
+
* a task, a step, when it began, how long so far if the host says, and an explicit end that
|
|
57
|
+
* carries the final duration.
|
|
58
|
+
*/
|
|
59
|
+
export declare function validateHandlingReport(report: unknown, path?: string, manifest?: unknown): ProtocolViolation[];
|
|
52
60
|
export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
|
|
53
61
|
/** The connection methods whose results `validateResult` knows. */
|
|
54
|
-
export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia" | "setPreference" | "executeTeamPolicy";
|
|
62
|
+
export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia" | "setPreference" | "recordStep" | "executeTeamPolicy";
|
|
55
63
|
/**
|
|
56
64
|
* Validates what a connection method answered. A result is untrusted for the same reason a
|
|
57
65
|
* snapshot is: it comes from an adapter that may be compiled against another version, and Omni
|
|
58
66
|
* shows the agent what it says. A status the method does not answer, a failure status without a
|
|
59
67
|
* failure, a success carrying one, or an `omni.` code the contract lacks are each refused.
|
|
60
68
|
*/
|
|
61
|
-
export declare function validateResult(result: unknown, method: ResultMethod, path?: string): ProtocolViolation[];
|
|
69
|
+
export declare function validateResult(result: unknown, method: ResultMethod, path?: string, dialId?: string): ProtocolViolation[];
|
|
62
70
|
/** What a login is validated against beyond its own shape. */
|
|
63
71
|
export interface LoginValidationContext {
|
|
64
72
|
/** The level ids in force, from the manifest. The defaults when absent. */
|