@xema/omni-protocol 0.1.101 → 0.1.103
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 +92 -81
- package/dist/index.js +8 -6
- package/dist/testing.d.ts +26 -1
- package/dist/testing.js +224 -35
- package/dist/validation.d.ts +8 -3
- package/dist/validation.js +178 -159
- package/guide.md +316 -210
- package/package.json +1 -1
package/dist/testing.js
CHANGED
|
@@ -24,7 +24,7 @@ const STATE_SUBJECTS = [
|
|
|
24
24
|
"break.reasons",
|
|
25
25
|
"break.forced",
|
|
26
26
|
"team.members",
|
|
27
|
-
"team.
|
|
27
|
+
"team.request",
|
|
28
28
|
"team.tasks",
|
|
29
29
|
"team.listening",
|
|
30
30
|
"team.shift",
|
|
@@ -37,7 +37,8 @@ const STATE_SUBJECTS = [
|
|
|
37
37
|
const EVENT_TYPES = {
|
|
38
38
|
snapshot: true, "transport-status": true, "break-state": true, "task-offered": true, "task-updated": true,
|
|
39
39
|
"task-audio-started": true, "task-audio-ended": true, "task-ended": true, "dial-outcome": true, announcement: true, "queue-summary": true, diagnostic: true,
|
|
40
|
-
"team-updated": true, "
|
|
40
|
+
"team-updated": true, "team-member-updated": true, "team-member-removed": true, "team-policies-updated": true,
|
|
41
|
+
"contacts-updated": true, "calendar-updated": true,
|
|
41
42
|
};
|
|
42
43
|
const CONTRACT_SUBJECTS = [
|
|
43
44
|
...STATE_SUBJECTS,
|
|
@@ -88,26 +89,31 @@ function observeBreak(value, seen) {
|
|
|
88
89
|
if (value.forced !== undefined)
|
|
89
90
|
seen.add("break.forced");
|
|
90
91
|
}
|
|
92
|
+
function observeMember(member, seen) {
|
|
93
|
+
if (!isRecord(member))
|
|
94
|
+
return;
|
|
95
|
+
if (some(member.tasks))
|
|
96
|
+
seen.add("team.tasks");
|
|
97
|
+
if (member.listening !== undefined)
|
|
98
|
+
seen.add("team.listening");
|
|
99
|
+
if (member.shift !== undefined)
|
|
100
|
+
seen.add("team.shift");
|
|
101
|
+
if (member.request !== undefined)
|
|
102
|
+
seen.add("team.request");
|
|
103
|
+
}
|
|
104
|
+
function observePolicies(value, seen) {
|
|
105
|
+
if (isRecord(value) && Object.keys(value).length > 0)
|
|
106
|
+
seen.add("team.policies");
|
|
107
|
+
}
|
|
91
108
|
function observeTeam(value, seen) {
|
|
92
109
|
if (!isRecord(value))
|
|
93
110
|
return;
|
|
94
111
|
if (some(value.members))
|
|
95
112
|
seen.add("team.members");
|
|
96
|
-
if (some(value.requests))
|
|
97
|
-
seen.add("team.requests");
|
|
98
113
|
if (Array.isArray(value.members))
|
|
99
|
-
for (const member of value.members)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (some(member.tasks))
|
|
103
|
-
seen.add("team.tasks");
|
|
104
|
-
if (member.listening !== undefined)
|
|
105
|
-
seen.add("team.listening");
|
|
106
|
-
if (member.shift !== undefined)
|
|
107
|
-
seen.add("team.shift");
|
|
108
|
-
}
|
|
109
|
-
if (isRecord(value.policies) && Object.keys(value.policies).length > 0)
|
|
110
|
-
seen.add("team.policies");
|
|
114
|
+
for (const member of value.members)
|
|
115
|
+
observeMember(member, seen);
|
|
116
|
+
observePolicies(value.policies, seen);
|
|
111
117
|
}
|
|
112
118
|
function observeSnapshot(value, seen) {
|
|
113
119
|
if (!isRecord(value))
|
|
@@ -141,6 +147,13 @@ function observeEvent(envelope, seen) {
|
|
|
141
147
|
case "team-updated":
|
|
142
148
|
observeTeam(event.team, seen);
|
|
143
149
|
break;
|
|
150
|
+
case "team-member-updated":
|
|
151
|
+
seen.add("team.members");
|
|
152
|
+
observeMember(event.member, seen);
|
|
153
|
+
break;
|
|
154
|
+
case "team-policies-updated":
|
|
155
|
+
observePolicies(event.policies, seen);
|
|
156
|
+
break;
|
|
144
157
|
case "contacts-updated":
|
|
145
158
|
if (some(event.contacts))
|
|
146
159
|
seen.add("contacts");
|
|
@@ -172,6 +185,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
172
185
|
const seen = new Set();
|
|
173
186
|
const stream = new TaskStream();
|
|
174
187
|
const breaks = new BreakStream();
|
|
188
|
+
const team = new TeamStream();
|
|
175
189
|
let seeded = false;
|
|
176
190
|
const duringRead = [];
|
|
177
191
|
let lockedPartySeen = false;
|
|
@@ -211,9 +225,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
211
225
|
throw new Error("unreachable: the exercise has an authenticated login");
|
|
212
226
|
return login;
|
|
213
227
|
};
|
|
228
|
+
// Off until the application has said otherwise: the provider assumes nothing of the team feature.
|
|
229
|
+
let leadFeaturesOn = false;
|
|
214
230
|
const reader = () => ({
|
|
215
231
|
self: current().identity.id,
|
|
216
232
|
capabilities: current().capabilities,
|
|
233
|
+
leadFeatures: leadFeaturesOn,
|
|
217
234
|
loginId: context.loginId,
|
|
218
235
|
autoAcceptTasks: context.autoAcceptTasks,
|
|
219
236
|
locked: options.lockedValues,
|
|
@@ -392,7 +409,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
392
409
|
// Cross-event rules apply once the stream has a beginning: the connect snapshot. An event
|
|
393
410
|
// delivered while that snapshot is read is held until it lands, and read then against it.
|
|
394
411
|
if (seeded)
|
|
395
|
-
violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
|
|
412
|
+
violations.push(...stream.apply(envelope), ...breaks.apply(envelope), ...team.apply(envelope));
|
|
396
413
|
else
|
|
397
414
|
duringRead.push(envelope);
|
|
398
415
|
events.push(envelope);
|
|
@@ -487,6 +504,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
487
504
|
await snapshotRead(live, snapshot, "snapshot");
|
|
488
505
|
stream.seed(snapshot);
|
|
489
506
|
violations.push(...breaks.seed(snapshot));
|
|
507
|
+
team.seed(snapshot);
|
|
490
508
|
seeded = true;
|
|
491
509
|
// A snapshot supersedes what it restates and nothing else. Of the events held during the read,
|
|
492
510
|
// a state-replacing kind is dropped, since the snapshot carries that state and must account for
|
|
@@ -500,7 +518,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
500
518
|
for (const held of duringRead) {
|
|
501
519
|
const event = held.event;
|
|
502
520
|
if (!SUPERSEDED_BY_A_SNAPSHOT.has(String(event.type))) {
|
|
503
|
-
violations.push(...stream.apply(held), ...breaks.apply(held));
|
|
521
|
+
violations.push(...stream.apply(held), ...breaks.apply(held), ...team.apply(held));
|
|
504
522
|
continue;
|
|
505
523
|
}
|
|
506
524
|
// Evaluated where a superseded event was held: with none, the question was never asked.
|
|
@@ -547,6 +565,42 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
547
565
|
message: `the host sent ${context.timeZone} at connect and the identity says ${String(current().identity.timeZone)}: the provider republishes the agent's time zone on the identity`,
|
|
548
566
|
});
|
|
549
567
|
}
|
|
568
|
+
// The provider assumes nothing of the team feature: the application says on every connect,
|
|
569
|
+
// and once it has said on, the whole team is owed, once. Before that nothing of the team
|
|
570
|
+
// reaches the lead, which the reader held the connect snapshot to.
|
|
571
|
+
if (current().capabilities.lead === true && typeof live.executeTeam === "function") {
|
|
572
|
+
const from = events.length;
|
|
573
|
+
let switched;
|
|
574
|
+
// On from the moment the application says so: the provider may answer with the team at once.
|
|
575
|
+
leadFeaturesOn = true;
|
|
576
|
+
try {
|
|
577
|
+
switched = await live.executeTeam({ command: { type: "lead-features", enabled: true } });
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
leadFeaturesOn = false;
|
|
581
|
+
violations.push({ rule: "team.switch.rejected", path: "connection.executeTeam", message: `lead-features on was rejected: ${String(error)}` });
|
|
582
|
+
}
|
|
583
|
+
if (switched !== undefined) {
|
|
584
|
+
violations.push(...validateResult(switched, "executeTeam", "connection.executeTeam"));
|
|
585
|
+
ruleEvaluated("team.required");
|
|
586
|
+
const ms = options.driveTimeoutMs ?? 5000;
|
|
587
|
+
const isBaseline = (envelope) => isRecord(envelope?.event) && envelope.event.type === "team-updated";
|
|
588
|
+
const baseline = events.slice(from).some(isBaseline) || await new Promise(resolve => {
|
|
589
|
+
const timer = setTimeout(() => { waiters.delete(waiter); resolve(false); }, ms);
|
|
590
|
+
const waiter = (envelope) => {
|
|
591
|
+
if (!isBaseline(envelope))
|
|
592
|
+
return;
|
|
593
|
+
clearTimeout(timer);
|
|
594
|
+
waiters.delete(waiter);
|
|
595
|
+
resolve(true);
|
|
596
|
+
};
|
|
597
|
+
waiters.add(waiter);
|
|
598
|
+
});
|
|
599
|
+
if (!baseline)
|
|
600
|
+
violations.push({ rule: "team.required", path: "event.team",
|
|
601
|
+
message: `the application switched the team feature on and no team-updated followed within ${ms}ms: the whole team is owed once it is on, members: [] when nobody is in it` });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
550
604
|
if (options.drive) {
|
|
551
605
|
const localAudio = isRecord(first) && isRecord(first.audio) && isRecord(first.audio.input) && first.audio.input.status === "available"
|
|
552
606
|
? first.audio.input.localAudio : undefined;
|
|
@@ -555,8 +609,15 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
555
609
|
everHeldForTask: (taskId) => [...watched.everHeld].some(key => namesTask(key, taskId)), snapshotRead,
|
|
556
610
|
timeoutMs: options.driveTimeoutMs ?? 5000,
|
|
557
611
|
context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, handOver,
|
|
558
|
-
streams: { stream, breaks },
|
|
612
|
+
streams: { stream, breaks, team },
|
|
559
613
|
}));
|
|
614
|
+
// Every offer is owed an ending. The drive ends the call it drove; anything else the
|
|
615
|
+
// provider offered and left open is an assignment whose ending never came.
|
|
616
|
+
ruleEvaluated("stream.taskOffered.unended");
|
|
617
|
+
for (const id of stream.unended()) {
|
|
618
|
+
violations.push({ rule: "stream.taskOffered.unended", path: "event.task-ended",
|
|
619
|
+
message: `${id} was offered and never ended: every offer is owed a task-ended, whatever became of the call` });
|
|
620
|
+
}
|
|
560
621
|
}
|
|
561
622
|
// Capacity supersedes rather than accumulates, so a decrease is as ordinary as an increase: the
|
|
562
623
|
// host raises it, lowers it, and takes it away, and the provider takes each as the ceiling it is.
|
|
@@ -641,7 +702,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
641
702
|
* its shape.
|
|
642
703
|
*/
|
|
643
704
|
function publishesUserIds(snapshot) {
|
|
644
|
-
if (snapshot?.break?.forced?.by !== undefined)
|
|
705
|
+
if (snapshot?.break?.forced?.by !== undefined && snapshot.break.forced.by !== "provider")
|
|
645
706
|
return true;
|
|
646
707
|
if (teamNamesUsers(snapshot?.team))
|
|
647
708
|
return true;
|
|
@@ -654,15 +715,13 @@ function userIdsIn(snapshot) {
|
|
|
654
715
|
const ids = new Set();
|
|
655
716
|
const add = (value) => { if (typeof value === "string" && value.length > 0)
|
|
656
717
|
ids.add(value); };
|
|
657
|
-
|
|
718
|
+
if (snapshot?.break?.forced?.by !== "provider")
|
|
719
|
+
add(snapshot?.break?.forced?.by);
|
|
658
720
|
const team = snapshot?.team;
|
|
659
721
|
if (isRecord(team)) {
|
|
660
722
|
for (const member of Array.isArray(team.members) ? team.members : [])
|
|
661
723
|
if (isRecord(member))
|
|
662
724
|
add(member.id);
|
|
663
|
-
for (const request of Array.isArray(team.requests) ? team.requests : [])
|
|
664
|
-
if (isRecord(request))
|
|
665
|
-
add(request.memberId);
|
|
666
725
|
}
|
|
667
726
|
for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
|
|
668
727
|
const t = task;
|
|
@@ -702,7 +761,7 @@ function userIdsIn(snapshot) {
|
|
|
702
761
|
}
|
|
703
762
|
return [...ids];
|
|
704
763
|
}
|
|
705
|
-
const teamNamesUsers = (team) => isRecord(team) &&
|
|
764
|
+
const teamNamesUsers = (team) => isRecord(team) && some(team.members);
|
|
706
765
|
const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.history) && Array.isArray(task.history.steps) && task.history.steps.some(step => isRecord(step) && step.by !== undefined)) ||
|
|
707
766
|
(isRecord(task.leadAssist) && task.leadAssist.leadId !== undefined) ||
|
|
708
767
|
isRecord(task.takenOver));
|
|
@@ -713,10 +772,12 @@ function eventNamesUsers(envelope) {
|
|
|
713
772
|
return false;
|
|
714
773
|
switch (event.type) {
|
|
715
774
|
case "snapshot": return publishesUserIds(event.snapshot);
|
|
716
|
-
case "break-state": return isRecord(event.break) && isRecord(event.break.forced) && event.break.forced.by !== undefined;
|
|
775
|
+
case "break-state": return isRecord(event.break) && isRecord(event.break.forced) && event.break.forced.by !== undefined && event.break.forced.by !== "provider";
|
|
717
776
|
case "task-offered":
|
|
718
777
|
case "task-updated": return taskNamesUsers(event.task);
|
|
719
778
|
case "team-updated": return teamNamesUsers(event.team);
|
|
779
|
+
case "team-member-updated": return isRecord(event.member) && event.member.id !== undefined;
|
|
780
|
+
case "team-member-removed": return true;
|
|
720
781
|
default: return false;
|
|
721
782
|
}
|
|
722
783
|
}
|
|
@@ -1001,7 +1062,8 @@ const REACHABLE_PHASES = {
|
|
|
1001
1062
|
*/
|
|
1002
1063
|
export const SUPERSEDED_BY_A_SNAPSHOT = new Set([
|
|
1003
1064
|
"snapshot", "transport-status", "break-state", "task-offered", "task-updated", "task-ended",
|
|
1004
|
-
"task-audio-started", "task-audio-ended", "team-updated", "
|
|
1065
|
+
"task-audio-started", "task-audio-ended", "team-updated", "team-member-updated", "team-member-removed", "team-policies-updated",
|
|
1066
|
+
"contacts-updated", "calendar-updated",
|
|
1005
1067
|
]);
|
|
1006
1068
|
export class TaskStream {
|
|
1007
1069
|
tasks = new Map();
|
|
@@ -1010,8 +1072,10 @@ export class TaskStream {
|
|
|
1010
1072
|
// a task that is open.
|
|
1011
1073
|
assignments = new Set();
|
|
1012
1074
|
endedAssignments = new Set();
|
|
1075
|
+
/** Every assignment a `task-offered` introduced, as against one a snapshot carried: each is owed its ending. */
|
|
1076
|
+
offered = new Set();
|
|
1013
1077
|
// Every dial the stream can place an outcome against: one the host said it placed, or one a
|
|
1014
|
-
// task carried on `onCall` or in its record -- which is how a dial made before a
|
|
1078
|
+
// task carried on `onCall` or in its record -- which is how a dial made before a take-over is known
|
|
1015
1079
|
// to whoever holds the task now. `answered` or `ended` once its outcome arrived, since it comes once.
|
|
1016
1080
|
dials = new Map();
|
|
1017
1081
|
/** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
|
|
@@ -1045,6 +1109,8 @@ export class TaskStream {
|
|
|
1045
1109
|
}
|
|
1046
1110
|
/** How many tasks the stream currently holds open. */
|
|
1047
1111
|
openCount() { return this.tasks.size; }
|
|
1112
|
+
/** The assignments an offer introduced that are still open: each was owed a `task-ended` that has not come. */
|
|
1113
|
+
unended() { return [...this.offered].filter(id => this.tasks.has(id)); }
|
|
1048
1114
|
/** Whether an offered task is a dial arriving -- the host's own call, a connect-back -- which counts against no capacity: an entry on its call carrying a dial. */
|
|
1049
1115
|
static carriesDial(task) {
|
|
1050
1116
|
if (!isRecord(task) || !Array.isArray(task.onCall))
|
|
@@ -1174,6 +1240,7 @@ export class TaskStream {
|
|
|
1174
1240
|
refuse("stream.taskOffered.duplicate", `${at}.task.assignmentId`, known !== undefined ? `${id} is already on the stream; an offer introduces an assignment once` : `${id} was already an assignment on this stream: an assignment is named once per offer and never reused, whatever the platform does with its own handle`);
|
|
1175
1241
|
}
|
|
1176
1242
|
this.assignments.add(id);
|
|
1243
|
+
this.offered.add(id);
|
|
1177
1244
|
this.tasks.set(id, TaskStream.stated(event.task));
|
|
1178
1245
|
this.noteDials(event.task);
|
|
1179
1246
|
break;
|
|
@@ -1219,7 +1286,7 @@ export class TaskStream {
|
|
|
1219
1286
|
}
|
|
1220
1287
|
// A task completes after its audio ends, never around it: an update that moves a task to
|
|
1221
1288
|
// completing while the stream holds its audio as started is a call whose audio never ended,
|
|
1222
|
-
// whoever caused the ending -- the drive's end-call, a
|
|
1289
|
+
// whoever caused the ending -- the drive's end-call, a take-over, the provider's own hand.
|
|
1223
1290
|
{
|
|
1224
1291
|
const to = isRecord(event.task) ? String(event.task.phase) : "";
|
|
1225
1292
|
if (to === "completing" && known.audio === "started" && known.phase !== "completing") {
|
|
@@ -1320,8 +1387,9 @@ export class TaskStream {
|
|
|
1320
1387
|
// that completes the task itself with an allowance to run has to have started the clock:
|
|
1321
1388
|
// completed from in-progress, the allowance it stated was never given.
|
|
1322
1389
|
ruleEvaluated("stream.taskEnded.unwrapped");
|
|
1390
|
+
// An agent's own complete cuts the wrap short from wherever the task stands, so that ending says by agent and is not this.
|
|
1323
1391
|
if (known.channel !== "voice" && known.completionMode === "provider-automatic" && known.wrapAllowance !== undefined && known.wrapAllowance > 0
|
|
1324
|
-
&& known.phase !== "completing" && isRecord(event.outcome) && event.outcome.type === "completed") {
|
|
1392
|
+
&& known.phase !== "completing" && isRecord(event.outcome) && event.outcome.type === "completed" && event.outcome.by === "provider") {
|
|
1325
1393
|
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 interaction ended and the allowance's start, and it was never published`);
|
|
1326
1394
|
}
|
|
1327
1395
|
this.endedAssignments.add(id);
|
|
@@ -1441,6 +1509,7 @@ async function driveOneCall(drive) {
|
|
|
1441
1509
|
await drive.snapshotRead(second, snapshot, "drive.reload.snapshot");
|
|
1442
1510
|
found.push(...drive.streams.stream.resync(snapshot, "drive.reload.snapshot"));
|
|
1443
1511
|
found.push(...drive.streams.breaks.seed(snapshot));
|
|
1512
|
+
drive.streams.team.seed(snapshot);
|
|
1444
1513
|
const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
|
|
1445
1514
|
? snapshot.tasks.find(task => isRecord(task) && task.assignmentId === taskId) : undefined;
|
|
1446
1515
|
ruleEvaluated("drive.reload.snapshot");
|
|
@@ -1557,6 +1626,7 @@ async function driveOneCall(drive) {
|
|
|
1557
1626
|
const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`, {
|
|
1558
1627
|
levels: effectiveLevels(drive.manifest.orgLevels).map(level => level.id),
|
|
1559
1628
|
dialOutcomesDeclared: drive.manifest.dialOutcomes !== undefined,
|
|
1629
|
+
calendarDeclared: drive.manifest.idleCapabilities?.calendar === true,
|
|
1560
1630
|
autoAcceptTasks: drive.context.autoAcceptTasks,
|
|
1561
1631
|
});
|
|
1562
1632
|
found.push(...own);
|
|
@@ -1729,6 +1799,38 @@ async function driveOneCall(drive) {
|
|
|
1729
1799
|
}
|
|
1730
1800
|
}
|
|
1731
1801
|
}
|
|
1802
|
+
// 4a. Schedule a follow-up, where the task offers it: applied says it is on the calendar, and the
|
|
1803
|
+
// calendar-updated that shows it is owed within the manifest's bound. The time is the provider's
|
|
1804
|
+
// own: a day after the instant it published the offer, so no clock of the runner's is read.
|
|
1805
|
+
if (latestTask().phase === "in-progress" && offers("schedule")) {
|
|
1806
|
+
const publishedAt = drive.events.slice(0, cursor).reverse().find(envelope => {
|
|
1807
|
+
const event = envelope.event;
|
|
1808
|
+
return (event.type === "task-offered" || event.type === "task-updated") && isTask(event.task) && event.task.assignmentId === taskId;
|
|
1809
|
+
})?.occurredAt;
|
|
1810
|
+
const at = new Date(Date.parse(String(publishedAt ?? "2026-01-01T00:00:00Z")) + 24 * 60 * 60 * 1000).toISOString();
|
|
1811
|
+
if (await send({ type: "schedule", at, note: "Follow-up promised on the call" }) !== undefined) {
|
|
1812
|
+
ruleEvaluated("drive.schedule.unsettled");
|
|
1813
|
+
const settle = Number(drive.manifest.settleMs);
|
|
1814
|
+
const carries = (calendar) => Array.isArray(calendar) && calendar.some(activity => isRecord(activity) && activity.startsAt === at);
|
|
1815
|
+
let unsettled = false;
|
|
1816
|
+
const shown = await waitFor("a calendar-updated carrying the follow-up", envelope => {
|
|
1817
|
+
const event = envelope.event;
|
|
1818
|
+
return event.type === "calendar-updated" && carries(event.calendar) ? event : undefined;
|
|
1819
|
+
}, cursor, { ms: settle, onExpiry: () => { unsettled = true; } });
|
|
1820
|
+
if (shown === undefined && unsettled) {
|
|
1821
|
+
let resync;
|
|
1822
|
+
try {
|
|
1823
|
+
resync = await drive.connection.snapshot();
|
|
1824
|
+
}
|
|
1825
|
+
catch (error) {
|
|
1826
|
+
refuse("drive.command.rejected", "drive.schedule", `snapshot() after an unsettled schedule rejected: ${String(error)}`);
|
|
1827
|
+
}
|
|
1828
|
+
if (!(isRecord(resync) && carries(resync.calendar))) {
|
|
1829
|
+
refuse("drive.schedule.unsettled", "drive.schedule", `schedule was applied and ${settle}ms later no calendar-updated carries a follow-up at ${at}, and a snapshot does not either: applied says the follow-up is on the calendar, and it is not`);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1732
1834
|
// 5. End the call, where the agent may -- with the microphone muted, as agents do. The leg left
|
|
1733
1835
|
// open at audio end is the provider's to close, since the provider knows the instant the audio
|
|
1734
1836
|
// ended; the host's own closing report can only follow what it hears, is answered recorded, and
|
|
@@ -1779,11 +1881,12 @@ async function driveOneCall(drive) {
|
|
|
1779
1881
|
refuse("drive.openAudio.close", "drive.openAudio", "the audio session threw on close");
|
|
1780
1882
|
}
|
|
1781
1883
|
}
|
|
1782
|
-
// 6. Complete,
|
|
1783
|
-
//
|
|
1784
|
-
//
|
|
1884
|
+
// 6. Complete, under either mode: the agent may finish early and the provider is free to end at
|
|
1885
|
+
// once. Only a task with no wrap at all has nothing to complete, and there the drive waits for the
|
|
1886
|
+
// provider. A conversation has no audio to end and no completing phase to wait for: a chat or an
|
|
1887
|
+
// email, and a voice task offering no end-call, is completed from where it stands.
|
|
1785
1888
|
const completable = latestTask().phase === "completing" || latestTask().phase === "in-progress" || latestTask().phase === "paused";
|
|
1786
|
-
if (latestTask().completionMode === "
|
|
1889
|
+
if (!(latestTask().completionMode === "provider-automatic" && latestTask().wrapAllowance === 0) && completable) {
|
|
1787
1890
|
const command = { type: "complete" };
|
|
1788
1891
|
const outcomes = isRecord(latestTask().capabilities) ? latestTask().capabilities.outcomes : undefined;
|
|
1789
1892
|
if (isRecord(outcomes) && outcomes.required === true && Array.isArray(outcomes.codes) && isRecord(outcomes.codes[0])) {
|
|
@@ -1794,7 +1897,7 @@ async function driveOneCall(drive) {
|
|
|
1794
1897
|
// the manifest stated. Past it the host resyncs: a snapshot still carrying the task is a task
|
|
1795
1898
|
// held open by a provider that said it was done, and the desk shows it as unsettled.
|
|
1796
1899
|
ruleEvaluated("drive.completion.unsettled");
|
|
1797
|
-
const settle = Number(drive.manifest.
|
|
1900
|
+
const settle = Number(drive.manifest.settleMs);
|
|
1798
1901
|
let unsettled = false;
|
|
1799
1902
|
const end = await ended({ ms: settle, onExpiry: () => { unsettled = true; } });
|
|
1800
1903
|
if (end !== undefined)
|
|
@@ -1863,6 +1966,63 @@ async function driveOneCall(drive) {
|
|
|
1863
1966
|
// preserves the committed break. A forced break arrives on-break
|
|
1864
1967
|
// with `forced`. Nothing else is a move the guide describes.
|
|
1865
1968
|
/** What a stream has said about the agent's break, and the moves it may not make. */
|
|
1969
|
+
/**
|
|
1970
|
+
* The team as the events left it: whole on `team-updated` and on any snapshot carrying one, then
|
|
1971
|
+
* one member at a time. A member event before the whole team, or a removal of a member the team
|
|
1972
|
+
* never carried, is a change to a team the lead was never given.
|
|
1973
|
+
*/
|
|
1974
|
+
export class TeamStream {
|
|
1975
|
+
members;
|
|
1976
|
+
static ids(team) {
|
|
1977
|
+
if (!isRecord(team) || !Array.isArray(team.members))
|
|
1978
|
+
return undefined;
|
|
1979
|
+
return new Set(team.members.filter(isRecord).map(member => String(member.id)));
|
|
1980
|
+
}
|
|
1981
|
+
/** A snapshot carrying a team is the team whole; one carrying none leaves the lead with none. */
|
|
1982
|
+
seed(snapshot) {
|
|
1983
|
+
this.members = TeamStream.ids(isRecord(snapshot) ? snapshot.team : undefined);
|
|
1984
|
+
}
|
|
1985
|
+
/** Whether the team currently carries this member, as the events left it. */
|
|
1986
|
+
has(id) { return this.members?.has(id) === true; }
|
|
1987
|
+
apply(envelope, path = "event") {
|
|
1988
|
+
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
1989
|
+
if (!isRecord(event))
|
|
1990
|
+
return [];
|
|
1991
|
+
const at = `${path}.event`;
|
|
1992
|
+
switch (event.type) {
|
|
1993
|
+
case "snapshot":
|
|
1994
|
+
this.seed(event.snapshot);
|
|
1995
|
+
return [];
|
|
1996
|
+
case "team-updated":
|
|
1997
|
+
this.members = TeamStream.ids(event.team);
|
|
1998
|
+
return [];
|
|
1999
|
+
case "team-member-updated":
|
|
2000
|
+
case "team-member-removed":
|
|
2001
|
+
case "team-policies-updated": {
|
|
2002
|
+
ruleEvaluated("stream.team.baseline");
|
|
2003
|
+
if (this.members === undefined) {
|
|
2004
|
+
return [{ rule: "stream.team.baseline", path: `${at}.type`,
|
|
2005
|
+
message: `${event.type} arrived before the team: team-updated carries the whole team first, and it changes one member at a time after that` }];
|
|
2006
|
+
}
|
|
2007
|
+
if (event.type === "team-member-updated") {
|
|
2008
|
+
if (isRecord(event.member) && typeof event.member.id === "string")
|
|
2009
|
+
this.members.add(event.member.id);
|
|
2010
|
+
return [];
|
|
2011
|
+
}
|
|
2012
|
+
if (event.type === "team-member-removed") {
|
|
2013
|
+
ruleEvaluated("stream.teamMember.unknown");
|
|
2014
|
+
const id = String(event.memberId);
|
|
2015
|
+
if (!this.members.has(id)) {
|
|
2016
|
+
return [{ rule: "stream.teamMember.unknown", path: `${at}.memberId`, message: `${id} is not a member the team carried: a removal takes off somebody the lead was given` }];
|
|
2017
|
+
}
|
|
2018
|
+
this.members.delete(id);
|
|
2019
|
+
}
|
|
2020
|
+
return [];
|
|
2021
|
+
}
|
|
2022
|
+
default: return [];
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
1866
2026
|
export class BreakStream {
|
|
1867
2027
|
state;
|
|
1868
2028
|
recovery = true;
|
|
@@ -1956,6 +2116,35 @@ export function stillHost(report = { online: true }, guarantees = {}, mute) {
|
|
|
1956
2116
|
return { guarantees, ...(mute === undefined ? {} : { mute }), report: () => report, subscribe: () => () => undefined };
|
|
1957
2117
|
}
|
|
1958
2118
|
const usableLogin = (status) => status === "authenticated" || status === "refreshing";
|
|
2119
|
+
/**
|
|
2120
|
+
* A break one provider forces stops the agent everywhere: the host states a capacity of zero on
|
|
2121
|
+
* every other usable provider holding capacity -- taken, never refused, and nothing the agent can
|
|
2122
|
+
* cancel -- and on the forcing provider states nothing, since the break itself holds the agent
|
|
2123
|
+
* there. A provider whose login is not usable is left alone, as a break attempt leaves it.
|
|
2124
|
+
*/
|
|
2125
|
+
export function assertForcedBreakStopsTheRest(forcedOn, candidates, stopped) {
|
|
2126
|
+
if (!candidates.some(candidate => candidate.id === forcedOn))
|
|
2127
|
+
throw new Error(`${forcedOn} is not a provider the host knows`);
|
|
2128
|
+
const chosen = new Set(stopped);
|
|
2129
|
+
for (const id of stopped) {
|
|
2130
|
+
if (!candidates.some(candidate => candidate.id === id))
|
|
2131
|
+
throw new Error(`${id} is not a provider the host knows`);
|
|
2132
|
+
}
|
|
2133
|
+
if (chosen.has(forcedOn))
|
|
2134
|
+
throw new Error(`${forcedOn} forced the break and holds the agent itself: its capacity is not restated`);
|
|
2135
|
+
for (const candidate of candidates) {
|
|
2136
|
+
if (candidate.id === forcedOn)
|
|
2137
|
+
continue;
|
|
2138
|
+
const expected = usableLogin(candidate.authentication) && candidate.holdsCapacity;
|
|
2139
|
+
if (expected && !chosen.has(candidate.id)) {
|
|
2140
|
+
throw new Error(`${candidate.id} can give the agent work and must be stopped: capacity 0 stated`);
|
|
2141
|
+
}
|
|
2142
|
+
if (!expected && chosen.has(candidate.id)) {
|
|
2143
|
+
const why = usableLogin(candidate.authentication) ? "holds no capacity" : `is ${candidate.authentication}`;
|
|
2144
|
+
throw new Error(`${candidate.id} ${why} and is not stopped: there is nothing to stop`);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
1959
2148
|
/**
|
|
1960
2149
|
* A break attempt asks every connected provider from which the agent can currently receive work.
|
|
1961
2150
|
* A provider whose login is not usable -- `expired` above all -- is not asked, whatever else is
|
package/dist/validation.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface TaskValidationContext {
|
|
|
39
39
|
autoAcceptTasks?: boolean;
|
|
40
40
|
/** Whether the manifest declares `dialOutcomes`. A task that may dial needs it to; absent, the question is not asked. */
|
|
41
41
|
dialOutcomesDeclared?: boolean;
|
|
42
|
+
/** Whether the manifest declares the `calendar` idle capability. A task that may schedule needs it to; absent, the question is not asked. */
|
|
43
|
+
calendarDeclared?: boolean;
|
|
42
44
|
/**
|
|
43
45
|
* The values the queue locked on this login's tasks -- a party's number or email -- as whoever
|
|
44
46
|
* runs the validator knows them. Where a task's party stands locked, no other field of it may
|
|
@@ -98,8 +100,9 @@ export interface ReaderContext {
|
|
|
98
100
|
/** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
|
|
99
101
|
loginId?: string;
|
|
100
102
|
/**
|
|
101
|
-
* Whether the lead has the team feature on, as the application last told the provider.
|
|
102
|
-
*
|
|
103
|
+
* Whether the lead has the team feature on, as the application last told the provider. The
|
|
104
|
+
* provider assumes nothing until told: on, a snapshot carries the team; off, nothing of the
|
|
105
|
+
* team is owed or expected. Unknown to a caller without it, and then unchecked either way.
|
|
103
106
|
*/
|
|
104
107
|
leadFeatures?: boolean;
|
|
105
108
|
/** The provider's channel, for a member's task that states none. Filled from the manifest by `validateSnapshot` and `validateEventEnvelope`. */
|
|
@@ -114,6 +117,8 @@ export interface ReaderContext {
|
|
|
114
117
|
autoAcceptTasks?: boolean;
|
|
115
118
|
}
|
|
116
119
|
export declare function validateTeamMembers(teamMembers: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
120
|
+
/** One member as the lead sees them, whole: on the list, and on `team-member-updated` alone. */
|
|
121
|
+
export declare function validateTeamMember(member: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
117
122
|
export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
118
123
|
export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
119
124
|
/**
|
|
@@ -143,7 +148,7 @@ export declare function validateTaskCommandRequest(request: unknown, task: unkno
|
|
|
143
148
|
/**
|
|
144
149
|
* What a command needs to be issuable, checked against the task it names: the capability the
|
|
145
150
|
* guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
|
|
146
|
-
*
|
|
151
|
+
* lead requested, somebody else on the call. Without a task only the command's
|
|
147
152
|
* own shape is checked. A host validates before sending, and an adapter before acting: a command
|
|
148
153
|
* for a control the task never offered is the host's error, and this names it.
|
|
149
154
|
*/
|