@xema/omni-protocol 0.1.66 → 0.1.67
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 +14 -3
- package/dist/testing.d.ts +12 -1
- package/dist/testing.js +148 -11
- package/dist/validation.d.ts +16 -2
- package/dist/validation.js +130 -22
- package/guide.md +57 -19
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -442,8 +442,8 @@ export type ConnectContext = {
|
|
|
442
442
|
protocolVersion: number;
|
|
443
443
|
/** The session that authenticated this connection. */
|
|
444
444
|
loginId: string;
|
|
445
|
-
/** Omni-side policy: whether the agent's tasks are accepted without asking them. */
|
|
446
|
-
autoAcceptTasks
|
|
445
|
+
/** Omni-side policy: whether the agent's tasks are accepted without asking them. Stated, never assumed: a host says which. */
|
|
446
|
+
autoAcceptTasks: boolean;
|
|
447
447
|
/**
|
|
448
448
|
* The zone the agent's day is reckoned in, the same value passed as
|
|
449
449
|
* `AuthenticationContext.timeZone`. The provider stores it on the agent and carries it on the
|
|
@@ -713,13 +713,24 @@ export declare const ON_CALL_ROLES: readonly ["party", "agent", "consulted", "co
|
|
|
713
713
|
* either way, and the `stage` it has reached: `ringing` until the dial is answered, `joined` after,
|
|
714
714
|
* stated so a snapshot says who is present without anyone having seen the outcome. Nobody ringing is
|
|
715
715
|
* held. `label` names a destination -- a person, a queue -- not a phrase. At most one `party` and
|
|
716
|
-
* one `consulted`: the consult commands name neither because there is exactly one.
|
|
716
|
+
* one `consulted`: the consult commands name neither because there is exactly one. The `party`
|
|
717
|
+
* carries a `stage` only while being dialled again on the same task -- a connect-back the host
|
|
718
|
+
* placed, carrying its `dialId`, or a callback the platform places itself, carrying none --
|
|
719
|
+
* `ringing` from the moment the dial is placed and `joined` on its answered outcome, so a host can
|
|
720
|
+
* show a call being placed rather than assert one that is still ringing. A party with no stage is
|
|
721
|
+
* on the call.
|
|
717
722
|
*/
|
|
718
723
|
export type OnCall = {
|
|
719
724
|
since: IsoTimestamp;
|
|
720
725
|
held?: true;
|
|
721
726
|
} & ({
|
|
722
727
|
role: "party";
|
|
728
|
+
dialId?: never;
|
|
729
|
+
stage?: never;
|
|
730
|
+
} | {
|
|
731
|
+
role: "party";
|
|
732
|
+
stage: OnCallStage;
|
|
733
|
+
dialId?: DialId;
|
|
723
734
|
} | {
|
|
724
735
|
role: "agent";
|
|
725
736
|
userId: UserId;
|
package/dist/testing.d.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface AdapterContractResult {
|
|
|
29
29
|
notExercised: readonly ContractSubject[];
|
|
30
30
|
/** Every violation observed. Non-empty only when `collectOnly` suppressed the throw. */
|
|
31
31
|
violations: readonly ProtocolViolation[];
|
|
32
|
+
/**
|
|
33
|
+
* Every rule the run evaluated, pass or fail: the validators' as each was applied, the stream's
|
|
34
|
+
* and the drive's as each case was considered. A rule absent here was never looked at, which a
|
|
35
|
+
* clean `violations` says nothing about; a test that needs a rule to have run asserts it here.
|
|
36
|
+
*/
|
|
37
|
+
rulesEvaluated: ReadonlySet<string>;
|
|
32
38
|
}
|
|
33
39
|
export interface ExerciseAdapterOptions {
|
|
34
40
|
/** Return violations in the result instead of throwing. Defaults to `false`. */
|
|
@@ -127,13 +133,18 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
|
|
|
127
133
|
* both.
|
|
128
134
|
*/
|
|
129
135
|
export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
|
|
130
|
-
/** What a stream has said about the tasks it carries, and the rules across events. */
|
|
131
136
|
export declare class TaskStream {
|
|
132
137
|
private readonly tasks;
|
|
133
138
|
private readonly dials;
|
|
134
139
|
/** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
|
|
135
140
|
dialled(dialId: string): void;
|
|
136
141
|
private noteDials;
|
|
142
|
+
/** How many tasks the stream currently holds open. */
|
|
143
|
+
openCount(): number;
|
|
144
|
+
/** 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. */
|
|
145
|
+
static carriesDial(task: unknown): boolean;
|
|
146
|
+
/** Whether the update shows the party being dialled again -- a connect-back, a platform's callback -- the one thing that brings a completing task back. */
|
|
147
|
+
private static partyDialled;
|
|
137
148
|
/** The entries of a task's record, each by step and instant, or undefined where the task carries no record. */
|
|
138
149
|
private static record;
|
|
139
150
|
/** What a restated record lost of the one read before it: nothing, or the entries by step and instant. */
|
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
|
|
2
|
-
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateHostMute, validateLoginStore, validateHandlingReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateDescribedUsers, observeRules, ruleEvaluated, validateHostMute, validateLoginStore, validateHandlingReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
4
4
|
/**
|
|
5
5
|
* A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
|
|
@@ -154,6 +154,8 @@ function observeEvent(envelope, seen) {
|
|
|
154
154
|
* asynchronous one — which would let a non-conforming async adapter pass.
|
|
155
155
|
*/
|
|
156
156
|
export async function exerciseAdapter(adapter, context, options = {}) {
|
|
157
|
+
const rulesEvaluated = new Set();
|
|
158
|
+
const stopObserving = observeRules(rule => { rulesEvaluated.add(rule); });
|
|
157
159
|
const violations = [...validateManifest(adapter.manifest)];
|
|
158
160
|
const events = [];
|
|
159
161
|
const seen = new Set();
|
|
@@ -198,7 +200,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
198
200
|
self: current().identity.id,
|
|
199
201
|
capabilities: current().capabilities,
|
|
200
202
|
loginId: context.loginId,
|
|
201
|
-
autoAcceptTasks: context.autoAcceptTasks
|
|
203
|
+
autoAcceptTasks: context.autoAcceptTasks,
|
|
202
204
|
});
|
|
203
205
|
// The optional methods are optional only until something declares a need for them. Each
|
|
204
206
|
// check pairs a method with the declaration that requires it, as the guide's Live-connection
|
|
@@ -301,7 +303,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
301
303
|
// record of that leg is the provider's to take; on a desk phone the host holds no microphone.
|
|
302
304
|
if (softphone)
|
|
303
305
|
requireMethod(live, "recordStep", "the login is on a softphone, whose microphone the host mutes");
|
|
304
|
-
const
|
|
306
|
+
const eventPayloads = new Map();
|
|
307
|
+
let capacityStated;
|
|
305
308
|
// The drive waits on events: each waiter is offered every envelope as it lands.
|
|
306
309
|
const waiters = new Set();
|
|
307
310
|
unsubscribe = connection.subscribe(envelope => {
|
|
@@ -316,14 +319,36 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
316
319
|
violations.push(...undeterminedTasks(eventTasks(envelope), "event"));
|
|
317
320
|
if (eventNamesUsers(envelope))
|
|
318
321
|
requireMethod(live, "describeUsers", "an event publishes a UserId");
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
+
// Work is pulled, never pushed: an offer before the host stated capacity is an allocation
|
|
323
|
+
// against nothing, and an offer beyond the count is one too many -- unless the task is the
|
|
324
|
+
// host's own dial arriving, which counts against nothing.
|
|
325
|
+
if (isRecord(envelope?.event) && envelope.event.type === "task-offered") {
|
|
326
|
+
if (capacityStated === undefined) {
|
|
327
|
+
violations.push({ rule: "stream.taskOffered.beforeCapacity", path: "event.task",
|
|
328
|
+
message: "a task was offered before the host stated any capacity: work is pulled, and nothing is allocated against a capacity nobody stated" });
|
|
329
|
+
}
|
|
330
|
+
else if (seeded && stream.openCount() >= capacityStated && !TaskStream.carriesDial(envelope.event.task)) {
|
|
331
|
+
violations.push({ rule: "stream.taskOffered.overCapacity", path: "event.task",
|
|
332
|
+
message: `a task was offered with ${stream.openCount()} already open against a stated capacity of ${capacityStated}` });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// A re-delivered envelope is harmless and applied once; the same id carrying a different
|
|
336
|
+
// payload is a reused id, which no dedupe can make harmless, and is named.
|
|
322
337
|
if (typeof envelope?.id === "string") {
|
|
323
|
-
|
|
338
|
+
const payload = JSON.stringify(envelope.event);
|
|
339
|
+
const earlier = eventPayloads.get(envelope.id);
|
|
340
|
+
if (earlier !== undefined) {
|
|
341
|
+
if (earlier !== payload) {
|
|
342
|
+
violations.push({ rule: "event.id.reused", path: "event.id",
|
|
343
|
+
message: `envelope ${envelope.id} was delivered again with a different event: an id names one event for the life of the login` });
|
|
344
|
+
}
|
|
324
345
|
return;
|
|
325
|
-
|
|
346
|
+
}
|
|
347
|
+
eventPayloads.set(envelope.id, payload);
|
|
326
348
|
}
|
|
349
|
+
// Cross-event rules apply once the stream has a beginning: the connect snapshot.
|
|
350
|
+
if (seeded)
|
|
351
|
+
violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
|
|
327
352
|
events.push(envelope);
|
|
328
353
|
for (const waiter of waiters)
|
|
329
354
|
waiter(envelope);
|
|
@@ -336,8 +361,26 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
336
361
|
breaks.seed(snapshot);
|
|
337
362
|
seeded = true;
|
|
338
363
|
requireCapabilityMethods(live, current().capabilities);
|
|
339
|
-
if (publishesUserIds(snapshot))
|
|
364
|
+
if (publishesUserIds(snapshot)) {
|
|
340
365
|
requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
|
|
366
|
+
// Required by presence is not enough: the names the snapshot published are looked up, and what
|
|
367
|
+
// comes back is held to the shape -- an empty answer to a roster of colleagues is named.
|
|
368
|
+
const named = userIdsIn(snapshot);
|
|
369
|
+
if (typeof live.describeUsers === "function" && named.length > 0) {
|
|
370
|
+
let described;
|
|
371
|
+
try {
|
|
372
|
+
described = await live.describeUsers(named);
|
|
373
|
+
violations.push(...validateDescribedUsers(described, named, "connection.describeUsers"));
|
|
374
|
+
if (Array.isArray(described) && described.length === 0) {
|
|
375
|
+
violations.push({ rule: "connection.describeUsers.empty", path: "connection.describeUsers",
|
|
376
|
+
message: `asked about ${named.join(", ")}, whom the snapshot itself named, the provider described nobody` });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
violations.push({ rule: "connection.describeUsers.rejected", path: "connection.describeUsers", message: `describeUsers rejected rather than answered: ${String(error)}` });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
341
384
|
// Capacity is stated, not requested: nothing may be allocated until it is, so a connection
|
|
342
385
|
// that will not accept one is a connection nothing can be given to.
|
|
343
386
|
// The guide's obligation on a voice adapter: consult the host before declaring the agent
|
|
@@ -349,7 +392,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
349
392
|
message: "a voice adapter consults the host's report before declaring the agent ready, and this one never asked",
|
|
350
393
|
});
|
|
351
394
|
}
|
|
352
|
-
|
|
395
|
+
capacityStated = 1;
|
|
396
|
+
const capacity = await connection.setCapacity({ count: capacityStated });
|
|
353
397
|
const malformed = validateResult(capacity, "setCapacity", "connection.setCapacity");
|
|
354
398
|
violations.push(...malformed);
|
|
355
399
|
// The provider republishes the agent's day: once connected, the identity carries the zone the
|
|
@@ -425,6 +469,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
425
469
|
}
|
|
426
470
|
if (!options.collectOnly)
|
|
427
471
|
assertNoViolations(violations);
|
|
472
|
+
stopObserving();
|
|
428
473
|
return {
|
|
429
474
|
events: events,
|
|
430
475
|
authenticationState: authenticationState,
|
|
@@ -432,6 +477,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
432
477
|
notExercised: CONTRACT_SUBJECTS.filter(subject => !seen.has(subject)),
|
|
433
478
|
disconnectWasClean,
|
|
434
479
|
violations,
|
|
480
|
+
rulesEvaluated,
|
|
435
481
|
};
|
|
436
482
|
}
|
|
437
483
|
/**
|
|
@@ -448,6 +494,42 @@ function publishesUserIds(snapshot) {
|
|
|
448
494
|
return false;
|
|
449
495
|
return snapshot.tasks.some(taskNamesUsers);
|
|
450
496
|
}
|
|
497
|
+
/** Every UserId a snapshot publishes: on the record, the room, a lead request, an imposed break, the roster. */
|
|
498
|
+
function userIdsIn(snapshot) {
|
|
499
|
+
const ids = new Set();
|
|
500
|
+
const add = (value) => { if (typeof value === "string" && value.length > 0)
|
|
501
|
+
ids.add(value); };
|
|
502
|
+
add(snapshot?.break?.imposed?.by);
|
|
503
|
+
const team = snapshot?.team;
|
|
504
|
+
if (isRecord(team)) {
|
|
505
|
+
for (const member of Array.isArray(team.members) ? team.members : [])
|
|
506
|
+
if (isRecord(member))
|
|
507
|
+
add(member.id);
|
|
508
|
+
for (const request of Array.isArray(team.requests) ? team.requests : [])
|
|
509
|
+
if (isRecord(request))
|
|
510
|
+
add(request.memberId);
|
|
511
|
+
}
|
|
512
|
+
for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
|
|
513
|
+
const t = task;
|
|
514
|
+
if (isRecord(t.handlingHistory) && Array.isArray(t.handlingHistory.steps))
|
|
515
|
+
for (const step of t.handlingHistory.steps)
|
|
516
|
+
if (isRecord(step))
|
|
517
|
+
add(step.by);
|
|
518
|
+
if (Array.isArray(t.onCall))
|
|
519
|
+
for (const entry of t.onCall)
|
|
520
|
+
if (isRecord(entry))
|
|
521
|
+
add(entry.userId);
|
|
522
|
+
if (isRecord(t.leadAssist))
|
|
523
|
+
add(t.leadAssist.leadId);
|
|
524
|
+
if (isRecord(t.assisting))
|
|
525
|
+
add(t.assisting.memberId);
|
|
526
|
+
if (isRecord(t.monitoring)) {
|
|
527
|
+
add(t.monitoring.memberId);
|
|
528
|
+
add(t.monitoring.agentId);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return [...ids];
|
|
532
|
+
}
|
|
451
533
|
const teamNamesUsers = (team) => isRecord(team) && (some(team.members) || some(team.requests));
|
|
452
534
|
const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.handlingHistory) && Array.isArray(task.handlingHistory.steps) && task.handlingHistory.steps.some(step => isRecord(step) && step.by !== undefined)) ||
|
|
453
535
|
(isRecord(task.leadAssist) && task.leadAssist.leadId !== undefined) ||
|
|
@@ -720,7 +802,19 @@ export function assertDeniedAndRetriedBreak(approvals) {
|
|
|
720
802
|
// completing or ending, never a phase the audio decided.
|
|
721
803
|
// ---------------------------------------------------------------------------
|
|
722
804
|
const WORK_BEGUN = new Set(["in-progress", "paused", "completing"]);
|
|
805
|
+
/** Where audio can arrive: a task at work. A completing task's call is over; a connect-back returns it to in-progress before any media. */
|
|
806
|
+
const AT_WORK = new Set(["in-progress", "paused"]);
|
|
723
807
|
/** What a stream has said about the tasks it carries, and the rules across events. */
|
|
808
|
+
const TASK_PHASES_ORDERED = ["pending", "confirmed", "preview", "in-progress", "paused", "completing"];
|
|
809
|
+
/** Where a task may be next, by the guide's transition table, closed over the phases between two publications. */
|
|
810
|
+
const REACHABLE_PHASES = {
|
|
811
|
+
pending: new Set(["pending", "confirmed", "preview", "in-progress", "paused", "completing"]),
|
|
812
|
+
confirmed: new Set(["confirmed", "preview", "in-progress", "paused", "completing"]),
|
|
813
|
+
preview: new Set(["preview", "in-progress", "paused", "completing"]),
|
|
814
|
+
"in-progress": new Set(["in-progress", "paused", "completing"]),
|
|
815
|
+
paused: new Set(["paused", "in-progress", "completing"]),
|
|
816
|
+
completing: new Set(["completing"]),
|
|
817
|
+
};
|
|
724
818
|
export class TaskStream {
|
|
725
819
|
tasks = new Map();
|
|
726
820
|
// Every dial the stream can place an outcome against: one the host said it placed, or one a
|
|
@@ -744,6 +838,19 @@ export class TaskStream {
|
|
|
744
838
|
this.dialled(entry.dialId);
|
|
745
839
|
}
|
|
746
840
|
}
|
|
841
|
+
/** How many tasks the stream currently holds open. */
|
|
842
|
+
openCount() { return this.tasks.size; }
|
|
843
|
+
/** 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. */
|
|
844
|
+
static carriesDial(task) {
|
|
845
|
+
if (!isRecord(task) || !Array.isArray(task.onCall))
|
|
846
|
+
return false;
|
|
847
|
+
return task.onCall.some(entry => isRecord(entry) && typeof entry.dialId === "string");
|
|
848
|
+
}
|
|
849
|
+
/** Whether the update shows the party being dialled again -- a connect-back, a platform's callback -- the one thing that brings a completing task back. */
|
|
850
|
+
static partyDialled(task) {
|
|
851
|
+
return isRecord(task) && Array.isArray(task.onCall)
|
|
852
|
+
&& task.onCall.some(entry => isRecord(entry) && entry.role === "party" && typeof entry.stage === "string");
|
|
853
|
+
}
|
|
747
854
|
/** The entries of a task's record, each by step and instant, or undefined where the task carries no record. */
|
|
748
855
|
static record(task) {
|
|
749
856
|
if (!isRecord(task) || !isRecord(task.handlingHistory) || !Array.isArray(task.handlingHistory.steps))
|
|
@@ -794,6 +901,7 @@ export class TaskStream {
|
|
|
794
901
|
const known = id === undefined ? undefined : this.tasks.get(id);
|
|
795
902
|
switch (event.type) {
|
|
796
903
|
case "snapshot":
|
|
904
|
+
ruleEvaluated("stream.snapshot.capabilitySource", "stream.snapshot.handlingHistory");
|
|
797
905
|
// A snapshot replaces what is known, and still may not say a task lost terms it had read.
|
|
798
906
|
if (isRecord(event.snapshot) && Array.isArray(event.snapshot.tasks)) {
|
|
799
907
|
event.snapshot.tasks.forEach((task, index) => {
|
|
@@ -813,6 +921,7 @@ export class TaskStream {
|
|
|
813
921
|
this.seed(event.snapshot);
|
|
814
922
|
break;
|
|
815
923
|
case "task-offered":
|
|
924
|
+
ruleEvaluated("stream.taskOffered.duplicate");
|
|
816
925
|
if (id === undefined)
|
|
817
926
|
break;
|
|
818
927
|
if (known !== undefined)
|
|
@@ -823,6 +932,7 @@ export class TaskStream {
|
|
|
823
932
|
case "task-updated":
|
|
824
933
|
if (id === undefined)
|
|
825
934
|
break;
|
|
935
|
+
ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
|
|
826
936
|
if (known === undefined) {
|
|
827
937
|
refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
|
|
828
938
|
break;
|
|
@@ -833,6 +943,22 @@ export class TaskStream {
|
|
|
833
943
|
if ((known.source === "queue" || known.source === "ungoverned") && isRecord(event.task) && event.task.capabilitySource === "undetermined") {
|
|
834
944
|
refuse("stream.taskUpdated.capabilitySource", `${at}.task.capabilitySource`, `${id} was published under ${known.source} terms and now says undetermined: terms once read stay read, and a re-read that fails is a diagnostic, not a republish`);
|
|
835
945
|
}
|
|
946
|
+
// A task does not go backwards. The stream sees publications, not transitions, and a task may
|
|
947
|
+
// pass through a phase between two, so what is refused is a phase unreachable from the last
|
|
948
|
+
// one read by the guide's table: back to pending, back to confirmed once work began, out of
|
|
949
|
+
// completing except through a connect-back -- which the update itself shows, the party
|
|
950
|
+
// carrying the host's dial.
|
|
951
|
+
{
|
|
952
|
+
const from = known.phase;
|
|
953
|
+
const to = isRecord(event.task) ? String(event.task.phase) : "";
|
|
954
|
+
const reachable = REACHABLE_PHASES[from];
|
|
955
|
+
if (reachable !== undefined && TASK_PHASES_ORDERED.includes(to) && !reachable.has(to)) {
|
|
956
|
+
const connectingBack = from === "completing" && TaskStream.partyDialled(event.task) && (to === "in-progress" || to === "paused");
|
|
957
|
+
if (!connectingBack) {
|
|
958
|
+
refuse("stream.taskUpdated.phase", `${at}.task.phase`, `${id} was ${from} and the update says ${to}: a task does not go backwards, and ${from === "completing" ? "only the party being dialled again, a connect-back or a callback, with its stage on the call, brings a completing task back" : `${to} is not reachable from ${from}`}`);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
836
962
|
// A record once read is not unread: an update restates it whole, or with more, never with less.
|
|
837
963
|
{
|
|
838
964
|
const lost = TaskStream.lost(known.record, TaskStream.record(event.task));
|
|
@@ -867,13 +993,14 @@ export class TaskStream {
|
|
|
867
993
|
this.noteDials(event.task);
|
|
868
994
|
break;
|
|
869
995
|
case "task-media-started":
|
|
996
|
+
ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate");
|
|
870
997
|
if (id === undefined)
|
|
871
998
|
break;
|
|
872
999
|
if (known === undefined) {
|
|
873
1000
|
refuse("stream.taskMediaStarted.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
874
1001
|
break;
|
|
875
1002
|
}
|
|
876
|
-
if (!
|
|
1003
|
+
if (!AT_WORK.has(known.phase)) {
|
|
877
1004
|
refuse("stream.taskMediaStarted.beforeWork", `${at}.taskId`, `media cannot arrive on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
878
1005
|
}
|
|
879
1006
|
if (known.media === "started") {
|
|
@@ -882,6 +1009,7 @@ export class TaskStream {
|
|
|
882
1009
|
known.media = "started";
|
|
883
1010
|
break;
|
|
884
1011
|
case "task-media-ended":
|
|
1012
|
+
ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent");
|
|
885
1013
|
if (id === undefined)
|
|
886
1014
|
break;
|
|
887
1015
|
if (known === undefined) {
|
|
@@ -977,6 +1105,15 @@ async function driveOneCall(drive) {
|
|
|
977
1105
|
return;
|
|
978
1106
|
}
|
|
979
1107
|
session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
|
|
1108
|
+
// The reload is a restore before it is anything else: the second session stands authenticated
|
|
1109
|
+
// as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
|
|
1110
|
+
const restored = await session.state();
|
|
1111
|
+
found.push(...validateAuthenticationState(restored, "drive.reload.login"));
|
|
1112
|
+
const same = isRecord(restored) && restored.status === "authenticated" && isRecord(restored.identity) && restored.identity.id === drive.reader().self;
|
|
1113
|
+
if (!same) {
|
|
1114
|
+
refuse("drive.reload.login", "drive.reload.login", `a second adapter built from the same login and secrets did not restore it: the session says ${String(isRecord(restored) ? restored.status : restored)}${isRecord(restored) && isRecord(restored.identity) ? ` as ${String(restored.identity.id)}` : ""}, and the login is ${String(drive.reader().self)}`);
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
980
1117
|
second = await again.connect(drive.context);
|
|
981
1118
|
const snapshot = await second.snapshot();
|
|
982
1119
|
// The second adapter's snapshot is held to everything a first one is.
|
package/dist/validation.d.ts
CHANGED
|
@@ -6,6 +6,14 @@ export declare class ProtocolConformanceError extends Error {
|
|
|
6
6
|
}
|
|
7
7
|
/** Throws `ProtocolConformanceError` when any violation is present. */
|
|
8
8
|
export declare function assertNoViolations(violations: readonly ProtocolViolation[], summary?: string): void;
|
|
9
|
+
/**
|
|
10
|
+
* Hears every rule a validator evaluates, pass or fail, until the returned function is called. A
|
|
11
|
+
* green run can then say which rules it looked at rather than only which subjects it reached: a
|
|
12
|
+
* rule never evaluated is a visible gap, not a pass. The harness registers one for each exercise.
|
|
13
|
+
*/
|
|
14
|
+
export declare function observeRules(listener: (rule: string) => void): () => void;
|
|
15
|
+
/** Says a rule was evaluated, for code that decides outside a collector: the stream, the drive. */
|
|
16
|
+
export declare function ruleEvaluated(...rules: readonly string[]): void;
|
|
9
17
|
/** Every field is optional, so this checks what is present rather than what is missing. */
|
|
10
18
|
export declare function validateContact(contact: unknown, path?: string): ProtocolViolation[];
|
|
11
19
|
export declare function validateScheduledActivity(activity: unknown, path?: string): ProtocolViolation[];
|
|
@@ -20,7 +28,7 @@ export interface TaskValidationContext {
|
|
|
20
28
|
channel: string;
|
|
21
29
|
/** The level ids in force, from the manifest. The defaults when absent. */
|
|
22
30
|
levels?: readonly string[];
|
|
23
|
-
/** `ConnectContext.autoAcceptTasks` as sent
|
|
31
|
+
/** `ConnectContext.autoAcceptTasks` as sent: whether a pending task states its `acceptance`. Unknown to a caller without the context, and then unchecked. */
|
|
24
32
|
autoAcceptTasks?: boolean;
|
|
25
33
|
/** Whether the manifest declares `dialOutcomes`. A task that may dial needs it to; absent, the question is not asked. */
|
|
26
34
|
dialOutcomesDeclared?: boolean;
|
|
@@ -45,7 +53,7 @@ export interface ReaderContext {
|
|
|
45
53
|
levels?: readonly string[];
|
|
46
54
|
/** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
|
|
47
55
|
loginId?: string;
|
|
48
|
-
/** `ConnectContext.autoAcceptTasks` as sent
|
|
56
|
+
/** `ConnectContext.autoAcceptTasks` as sent: whether a pending task states its `acceptance`. Unknown to a caller without the context, and then unchecked. */
|
|
49
57
|
autoAcceptTasks?: boolean;
|
|
50
58
|
}
|
|
51
59
|
export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
@@ -94,6 +102,12 @@ export declare function isTimeZone(value: unknown): value is string;
|
|
|
94
102
|
export declare function sameTimeZone(a: unknown, b: unknown): boolean;
|
|
95
103
|
/** The zone a host sends at connect: required, and an IANA name. */
|
|
96
104
|
export declare function validateTimeZone(value: unknown, path?: string): ProtocolViolation[];
|
|
105
|
+
/**
|
|
106
|
+
* What `describeUsers(ids)` answered: an array of users, each one asked for, each a valid identity.
|
|
107
|
+
* The provider may answer fewer than asked, which is what "unknown to it" looks like; it never
|
|
108
|
+
* answers somebody nobody asked about.
|
|
109
|
+
*/
|
|
110
|
+
export declare function validateDescribedUsers(users: unknown, asked: readonly string[], path?: string): ProtocolViolation[];
|
|
97
111
|
/** What a login is validated against beyond its own shape. */
|
|
98
112
|
export interface LoginValidationContext {
|
|
99
113
|
/** The level ids in force, from the manifest. The defaults when absent. */
|
package/dist/validation.js
CHANGED
|
@@ -131,14 +131,32 @@ const isDurationSeconds = (value) => typeof value === "number" && Number.isInteg
|
|
|
131
131
|
/** Opaque, non-empty, provider-issued. Never parsed and never compared across providers. */
|
|
132
132
|
const isUserId = isFilled;
|
|
133
133
|
const isTaskId = isFilled;
|
|
134
|
+
const ruleListeners = new Set();
|
|
135
|
+
/**
|
|
136
|
+
* Hears every rule a validator evaluates, pass or fail, until the returned function is called. A
|
|
137
|
+
* green run can then say which rules it looked at rather than only which subjects it reached: a
|
|
138
|
+
* rule never evaluated is a visible gap, not a pass. The harness registers one for each exercise.
|
|
139
|
+
*/
|
|
140
|
+
export function observeRules(listener) {
|
|
141
|
+
ruleListeners.add(listener);
|
|
142
|
+
return () => { ruleListeners.delete(listener); };
|
|
143
|
+
}
|
|
144
|
+
/** Says a rule was evaluated, for code that decides outside a collector: the stream, the drive. */
|
|
145
|
+
export function ruleEvaluated(...rules) {
|
|
146
|
+
for (const rule of rules)
|
|
147
|
+
for (const listener of ruleListeners)
|
|
148
|
+
listener(rule);
|
|
149
|
+
}
|
|
134
150
|
class Collector {
|
|
135
151
|
violations = [];
|
|
136
152
|
add(rule, path, message) {
|
|
153
|
+
ruleEvaluated(rule);
|
|
137
154
|
this.violations.push({ rule, path, message });
|
|
138
155
|
}
|
|
139
156
|
require(condition, rule, path, message) {
|
|
157
|
+
ruleEvaluated(rule);
|
|
140
158
|
if (!condition)
|
|
141
|
-
this.
|
|
159
|
+
this.violations.push({ rule, path, message });
|
|
142
160
|
return Boolean(condition);
|
|
143
161
|
}
|
|
144
162
|
filled(value, rule, path, message) {
|
|
@@ -198,7 +216,7 @@ export function validateScheduledActivity(activity, path = "scheduledActivity")
|
|
|
198
216
|
validateScheduledActivityInto(activity, path, into);
|
|
199
217
|
return into.violations;
|
|
200
218
|
}
|
|
201
|
-
function validateScheduledActivityInto(activity, path, into) {
|
|
219
|
+
function validateScheduledActivityInto(activity, path, into, levels) {
|
|
202
220
|
if (!isPlainObject(activity)) {
|
|
203
221
|
into.add("activity.shape", path, "a scheduled activity must be an object");
|
|
204
222
|
return;
|
|
@@ -213,7 +231,7 @@ function validateScheduledActivityInto(activity, path, into) {
|
|
|
213
231
|
}
|
|
214
232
|
}
|
|
215
233
|
if (activity.party !== undefined)
|
|
216
|
-
validateContactInto(activity.party, `${path}.party`, into);
|
|
234
|
+
validateContactInto(activity.party, `${path}.party`, into, levels);
|
|
217
235
|
validateAttributes(activity.attributes, `${path}.attributes`, into);
|
|
218
236
|
}
|
|
219
237
|
// ---------------------------------------------------------------------------
|
|
@@ -672,7 +690,7 @@ function validateBrowsers(value, path, into) {
|
|
|
672
690
|
}
|
|
673
691
|
});
|
|
674
692
|
}
|
|
675
|
-
function validateTaskAttributes(value, path, into) {
|
|
693
|
+
function validateTaskAttributes(value, path, into, levels) {
|
|
676
694
|
if (value === undefined)
|
|
677
695
|
return;
|
|
678
696
|
if (!Array.isArray(value)) {
|
|
@@ -699,7 +717,7 @@ function validateTaskAttributes(value, path, into) {
|
|
|
699
717
|
into.require(typeof attribute.value === "string", "task.attribute.text", `${at}.value`, "a text attribute needs a string value");
|
|
700
718
|
break;
|
|
701
719
|
case "contact":
|
|
702
|
-
validateContactInto(attribute.
|
|
720
|
+
validateContactInto(attribute.party, `${at}.party`, into, levels);
|
|
703
721
|
break;
|
|
704
722
|
case "timestamp":
|
|
705
723
|
into.timestamp(attribute.at, "task.attribute.timestamp", `${at}.at`);
|
|
@@ -791,13 +809,18 @@ function validateHandlingHistory(value, path, into, task = {}) {
|
|
|
791
809
|
/** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
|
|
792
810
|
const TASK_MEDIA_STATES = membersOf({ started: true, ended: true });
|
|
793
811
|
/** Real-time media is a voice affair, and its state is one of two words. */
|
|
794
|
-
function validateTaskMedia(value, channel, path, into) {
|
|
812
|
+
function validateTaskMedia(value, channel, phase, path, into) {
|
|
795
813
|
if (value === undefined)
|
|
796
814
|
return;
|
|
797
815
|
if (!into.require(channel === "voice", "task.media.channel", path, `a ${channel} task carries no real-time media state`))
|
|
798
816
|
return;
|
|
799
|
-
into.oneOf(value, TASK_MEDIA_STATES, "task.media", path)
|
|
817
|
+
if (into.oneOf(value, TASK_MEDIA_STATES, "task.media", path)) {
|
|
818
|
+
// Nothing is acquired while pending, and a preview has placed no call: media names a task whose
|
|
819
|
+
// work has begun, on a snapshot as on the event, or a host would open the microphone on an offer.
|
|
820
|
+
into.require(!WORK_NOT_BEGUN.includes(phase), "task.media.beforeWork", path, `a ${String(phase)} task has no media: audio arrives once its work has begun`);
|
|
821
|
+
}
|
|
800
822
|
}
|
|
823
|
+
const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
|
|
801
824
|
/**
|
|
802
825
|
* Who is on the call, as the provider states it. Voice only. Each entry is a role with what that
|
|
803
826
|
* role needs and nothing another role would: a party or an agent dialled nowhere, so neither
|
|
@@ -845,9 +868,23 @@ function validateOnCall(value, channel, path, into) {
|
|
|
845
868
|
if (entry.label !== undefined)
|
|
846
869
|
into.filled(entry.label, "task.onCall.label", `${at}.label`, "a label must not be empty when present");
|
|
847
870
|
}
|
|
871
|
+
else if (role === "party" && (entry.dialId !== undefined || entry.stage !== undefined)) {
|
|
872
|
+
// The party is dialled again on the same task -- a connect-back the host placed, a callback the
|
|
873
|
+
// platform placed -- and then carries the stage the dial has reached; a dial with no stage is
|
|
874
|
+
// half a claim, and the host's dialId is present where a host placed it, as on any dialled entry.
|
|
875
|
+
into.require(entry.stage !== undefined, "task.onCall.party.dial", at, "a party being dialled again carries the stage the dial has reached; the host's dialId beside it where a host placed the dial");
|
|
876
|
+
if (entry.dialId !== undefined)
|
|
877
|
+
into.filled(entry.dialId, "task.onCall.dialId", `${at}.dialId`, "dialId must not be empty when present");
|
|
878
|
+
if (entry.stage !== undefined && into.oneOf(entry.stage, ON_CALL_STAGES, "task.onCall.stage", `${at}.stage`)) {
|
|
879
|
+
into.require(!(entry.stage === "ringing" && entry.held === true), "task.onCall.held.ringing", `${at}.held`, "nobody ringing is held; held belongs to somebody who has joined");
|
|
880
|
+
}
|
|
881
|
+
for (const field of ["destinationId", "label"]) {
|
|
882
|
+
into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a party is not a directory item; ${field} belongs on consulted or conferenced`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
848
885
|
else {
|
|
849
886
|
for (const field of ["destinationId", "dialId", "label", "stage"]) {
|
|
850
|
-
into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a ${role} was dialled from nowhere; ${field} belongs on consulted or conferenced`);
|
|
887
|
+
into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, role === "party" ? `a party carries ${field} on a connect-back alone, with its dial and stage together` : `a ${role} was dialled from nowhere; ${field} belongs on consulted or conferenced`);
|
|
851
888
|
}
|
|
852
889
|
}
|
|
853
890
|
});
|
|
@@ -975,7 +1012,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
975
1012
|
if (task.party !== undefined)
|
|
976
1013
|
validateContactInto(task.party, `${path}.party`, into, context.levels);
|
|
977
1014
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
978
|
-
validateTaskAttributes(task.attributes, `${path}.attributes`, into);
|
|
1015
|
+
validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
|
|
979
1016
|
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into, { phase: task.phase, media: task.media });
|
|
980
1017
|
validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
|
|
981
1018
|
// The room is who is on the call now, and a task outlives its call by the whole of wrap-up: a
|
|
@@ -984,7 +1021,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
984
1021
|
if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
|
|
985
1022
|
into.add("task.onCall.ended", `${path}.onCall`, `the call has ended (${task.phase === "completing" ? "the task is completing" : "its media ended"}) and onCall still names people on it: the room is who is on the call now, and now nobody is`);
|
|
986
1023
|
}
|
|
987
|
-
validateTaskMedia(task.media, context.channel, `${path}.media`, into);
|
|
1024
|
+
validateTaskMedia(task.media, context.channel, task.phase, `${path}.media`, into);
|
|
988
1025
|
validateLeadAssist(task.leadAssist, context.channel, `${path}.leadAssist`, into);
|
|
989
1026
|
validateAssisting(task.assisting, context.channel, `${path}.assisting`, into);
|
|
990
1027
|
validateMonitoring(task.monitoring, context.channel, `${path}.monitoring`, into);
|
|
@@ -1320,7 +1357,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1320
1357
|
if (snapshot.contacts !== undefined) {
|
|
1321
1358
|
into.require(idle.contacts === true, "snapshot.contacts.capability", `${path}.contacts`, "contacts require the contacts idle capability");
|
|
1322
1359
|
if (Array.isArray(snapshot.contacts)) {
|
|
1323
|
-
snapshot.contacts.forEach((contact, index) => validateContactInto(contact, `${path}.contacts[${index}]`, into));
|
|
1360
|
+
snapshot.contacts.forEach((contact, index) => validateContactInto(contact, `${path}.contacts[${index}]`, into, levels));
|
|
1324
1361
|
}
|
|
1325
1362
|
else {
|
|
1326
1363
|
into.add("snapshot.contacts.shape", `${path}.contacts`, "contacts must be an array when present");
|
|
@@ -1331,7 +1368,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1331
1368
|
if (Array.isArray(snapshot.scheduledActivities)) {
|
|
1332
1369
|
const seen = new Set();
|
|
1333
1370
|
snapshot.scheduledActivities.forEach((activity, index) => {
|
|
1334
|
-
validateScheduledActivityInto(activity, `${path}.scheduledActivities[${index}]`, into);
|
|
1371
|
+
validateScheduledActivityInto(activity, `${path}.scheduledActivities[${index}]`, into, levels);
|
|
1335
1372
|
if (isPlainObject(activity) && isFilled(activity.id)) {
|
|
1336
1373
|
if (seen.has(activity.id)) {
|
|
1337
1374
|
into.add("activity.id.unique", `${path}.scheduledActivities[${index}].id`, `duplicate activity id: ${activity.id}`);
|
|
@@ -1535,7 +1572,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1535
1572
|
into.add("event.contacts.shape", `${at}.contacts`, "contacts must be an array");
|
|
1536
1573
|
}
|
|
1537
1574
|
else {
|
|
1538
|
-
event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into));
|
|
1575
|
+
event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into, levels));
|
|
1539
1576
|
}
|
|
1540
1577
|
break;
|
|
1541
1578
|
case "calendar-updated":
|
|
@@ -1546,7 +1583,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1546
1583
|
else {
|
|
1547
1584
|
const ids = new Set();
|
|
1548
1585
|
event.scheduledActivities.forEach((activity, index) => {
|
|
1549
|
-
validateScheduledActivityInto(activity, `${at}.scheduledActivities[${index}]`, into);
|
|
1586
|
+
validateScheduledActivityInto(activity, `${at}.scheduledActivities[${index}]`, into, levels);
|
|
1550
1587
|
if (isPlainObject(activity) && isFilled(activity.id)) {
|
|
1551
1588
|
if (ids.has(activity.id))
|
|
1552
1589
|
into.add("activity.id.unique", `${at}.scheduledActivities[${index}].id`, `duplicate activity id: ${activity.id}`);
|
|
@@ -1826,6 +1863,22 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1826
1863
|
: [...new Set([...TASK_COMMAND_NAMES.voice, ...TASK_COMMAND_NAMES.chat, ...TASK_COMMAND_NAMES.email])];
|
|
1827
1864
|
if (type === "custom") {
|
|
1828
1865
|
into.filled(command.name, "command.custom.name", `${path}.name`, "a custom command names the control it presses");
|
|
1866
|
+
if (task === undefined)
|
|
1867
|
+
return into.violations;
|
|
1868
|
+
// Held to the task: the control it presses is one the task published, and what the control asked for travels with it.
|
|
1869
|
+
const custom = isPlainObject(task) && isPlainObject(task.capabilities) ? task.capabilities.custom : undefined;
|
|
1870
|
+
const control = Array.isArray(custom) ? custom.filter(isPlainObject).find(entry => entry.id === command.name) : undefined;
|
|
1871
|
+
if (control === undefined) {
|
|
1872
|
+
into.add("command.capability.custom", path, `custom ${String(command.name)} presses a control the task never published`);
|
|
1873
|
+
return into.violations;
|
|
1874
|
+
}
|
|
1875
|
+
if (isPlainObject(control.prompt) && Array.isArray(control.prompt.fields)) {
|
|
1876
|
+
for (const field of control.prompt.fields.filter(isPlainObject)) {
|
|
1877
|
+
if (typeof field.name === "string") {
|
|
1878
|
+
into.require(typeof command[field.name] === "string" && command[field.name].length > 0, "command.custom.prompt", `${path}.${field.name}`, `the control asked for ${field.name} and the command does not carry it`);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1829
1882
|
return into.violations;
|
|
1830
1883
|
}
|
|
1831
1884
|
if (!into.require(typeof type === "string" && names.includes(type), "command.type", `${path}.type`, channel === undefined ? `unsupported command: ${String(type)}` : `a ${channel} task has no ${String(type)} command`)) {
|
|
@@ -1911,12 +1964,17 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1911
1964
|
// to act on yet, and in completing the handling has ended: a call with nobody on it, a
|
|
1912
1965
|
// conversation closed. What a capability offers, the phase decides whether there is anything to use it on.
|
|
1913
1966
|
const handling = () => inPhase("command.phase.handling", "in-progress", "paused");
|
|
1967
|
+
// A destination is one the directory offered: the id Omni sends is the id the provider published.
|
|
1968
|
+
const listed = (name) => {
|
|
1969
|
+
const declared = capabilities[name];
|
|
1970
|
+
const directory = isPlainObject(declared) && Array.isArray(declared.destinations) ? declared.destinations.filter(isPlainObject) : [];
|
|
1971
|
+
into.require(directory.some(item => item.id === command.destinationId), "command.destination.unknown", `${path}.destinationId`, `${String(command.destinationId)} is not a destination the task's ${name} directory offered`);
|
|
1972
|
+
};
|
|
1914
1973
|
const onCall = Array.isArray(task.onCall) ? task.onCall.filter(isPlainObject) : [];
|
|
1915
1974
|
switch (type) {
|
|
1916
1975
|
case "answer":
|
|
1917
1976
|
case "accept":
|
|
1918
1977
|
case "decline":
|
|
1919
|
-
case "reject":
|
|
1920
1978
|
inPhase("command.phase.pending", "pending");
|
|
1921
1979
|
if (type === "decline")
|
|
1922
1980
|
offered("decline");
|
|
@@ -1944,10 +2002,14 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1944
2002
|
break;
|
|
1945
2003
|
case "transfer":
|
|
1946
2004
|
handling();
|
|
1947
|
-
if (command.action === "cold")
|
|
1948
|
-
offered("coldTransfer")
|
|
1949
|
-
|
|
1950
|
-
|
|
2005
|
+
if (command.action === "cold") {
|
|
2006
|
+
if (offered("coldTransfer"))
|
|
2007
|
+
listed("coldTransfer");
|
|
2008
|
+
}
|
|
2009
|
+
else if (command.action === "warm") {
|
|
2010
|
+
if (offered("warmTransfer"))
|
|
2011
|
+
listed("warmTransfer");
|
|
2012
|
+
}
|
|
1951
2013
|
else if (command.action === "complete" || command.action === "cancel") {
|
|
1952
2014
|
into.require(onCall.some(entry => entry.role === "consulted"), "command.transfer.consulted", path, `${String(command.action)} needs a consulted entry on onCall: without one there is nothing to complete or cancel`);
|
|
1953
2015
|
}
|
|
@@ -1964,9 +2026,12 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1964
2026
|
into.require(task.assisting !== undefined, "command.leadAssist.assisting", path, `${String(command.action)} is the lead's own act on a call they joined: the task carries assisting`);
|
|
1965
2027
|
}
|
|
1966
2028
|
break;
|
|
1967
|
-
case "conference":
|
|
2029
|
+
case "conference": {
|
|
1968
2030
|
handling();
|
|
1969
|
-
|
|
2031
|
+
const conferencing = offered("conference");
|
|
2032
|
+
if (conferencing && command.action === "add")
|
|
2033
|
+
listed("conference");
|
|
2034
|
+
if (conferencing && command.action === "remove") {
|
|
1970
2035
|
const others = onCall.filter(entry => entry.role !== "agent");
|
|
1971
2036
|
if (command.party === true) {
|
|
1972
2037
|
into.require(others.some(entry => entry.role !== "party"), "command.conference.remove.alone", path, "removing the party would leave the agent alone: that is end-call");
|
|
@@ -1977,9 +2042,32 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1977
2042
|
}
|
|
1978
2043
|
}
|
|
1979
2044
|
break;
|
|
1980
|
-
|
|
2045
|
+
}
|
|
2046
|
+
case "complete": {
|
|
1981
2047
|
into.require(task.completionMode === "agent-command", "command.complete.mode", path, "complete belongs to agent-command; a provider-automatic task completes itself");
|
|
2048
|
+
// What travels with complete is what the dispositions capability published: a code from its
|
|
2049
|
+
// list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
|
|
2050
|
+
const dispositions = capabilities.dispositions;
|
|
2051
|
+
if (dispositions === undefined) {
|
|
2052
|
+
into.require(command.disposition === undefined, "command.complete.disposition.unexpected", `${path}.disposition`, "the task declares no dispositions; complete travels with no code");
|
|
2053
|
+
into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task declares no dispositions; complete travels with no notes");
|
|
2054
|
+
}
|
|
2055
|
+
else if (isPlainObject(dispositions)) {
|
|
2056
|
+
if (Array.isArray(dispositions.codes) && command.disposition !== undefined) {
|
|
2057
|
+
into.require(dispositions.codes.some(code => isPlainObject(code) && code.id === command.disposition), "command.complete.disposition.unknown", `${path}.disposition`, `${String(command.disposition)} is not a code the task published`);
|
|
2058
|
+
}
|
|
2059
|
+
if (dispositions.required === true) {
|
|
2060
|
+
into.require(command.disposition !== undefined, "command.complete.disposition.required", `${path}.disposition`, "the task requires a disposition and complete carries none");
|
|
2061
|
+
}
|
|
2062
|
+
if (dispositions.notes === "none") {
|
|
2063
|
+
into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task takes no notes");
|
|
2064
|
+
}
|
|
2065
|
+
else if (dispositions.notes === "required") {
|
|
2066
|
+
into.require(typeof command.notes === "string" && command.notes.length > 0, "command.complete.notes.required", `${path}.notes`, "the task requires notes and complete carries none");
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
1982
2069
|
break;
|
|
2070
|
+
}
|
|
1983
2071
|
default:
|
|
1984
2072
|
break;
|
|
1985
2073
|
}
|
|
@@ -2076,6 +2164,26 @@ export function validateTimeZone(value, path = "context.timeZone") {
|
|
|
2076
2164
|
into.require(isTimeZone(value), "context.timeZone", path, "a host states the agent's time zone at connect as an IANA name, such as Asia/Kolkata; an offset cannot survive a daylight-saving boundary");
|
|
2077
2165
|
return into.violations;
|
|
2078
2166
|
}
|
|
2167
|
+
/**
|
|
2168
|
+
* What `describeUsers(ids)` answered: an array of users, each one asked for, each a valid identity.
|
|
2169
|
+
* The provider may answer fewer than asked, which is what "unknown to it" looks like; it never
|
|
2170
|
+
* answers somebody nobody asked about.
|
|
2171
|
+
*/
|
|
2172
|
+
export function validateDescribedUsers(users, asked, path = "describeUsers") {
|
|
2173
|
+
const into = new Collector();
|
|
2174
|
+
if (!Array.isArray(users)) {
|
|
2175
|
+
into.add("describeUsers.shape", path, "describeUsers answers an array of users, empty when it knows none of them");
|
|
2176
|
+
return into.violations;
|
|
2177
|
+
}
|
|
2178
|
+
users.forEach((user, index) => {
|
|
2179
|
+
const at = `${path}[${index}]`;
|
|
2180
|
+
validateUser(user, "describeUsers.user", at, into);
|
|
2181
|
+
if (isPlainObject(user) && typeof user.id === "string") {
|
|
2182
|
+
into.require(asked.includes(user.id), "describeUsers.unasked", `${at}.id`, `${user.id} was not among the ids asked for`);
|
|
2183
|
+
}
|
|
2184
|
+
});
|
|
2185
|
+
return into.violations;
|
|
2186
|
+
}
|
|
2079
2187
|
function validateUser(value, rule, path, into) {
|
|
2080
2188
|
if (!isPlainObject(value)) {
|
|
2081
2189
|
into.add(rule, path, "an identity must be an object");
|
package/guide.md
CHANGED
|
@@ -314,7 +314,7 @@ type LoginStore = {
|
|
|
314
314
|
type ConnectContext = {
|
|
315
315
|
protocolVersion: number;
|
|
316
316
|
loginId: string;
|
|
317
|
-
autoAcceptTasks
|
|
317
|
+
autoAcceptTasks: boolean;
|
|
318
318
|
timeZone: TimeZone;
|
|
319
319
|
store: LoginStore;
|
|
320
320
|
phone?: Phone;
|
|
@@ -621,7 +621,8 @@ type OnCallRole = "party" | "agent" | "consulted" | "conferenced";
|
|
|
621
621
|
type OnCallStage = "ringing" | "joined";
|
|
622
622
|
|
|
623
623
|
type OnCall = { since: IsoTimestamp; held?: true } & (
|
|
624
|
-
| { role: "party" }
|
|
624
|
+
| { role: "party"; dialId?: never; stage?: never }
|
|
625
|
+
| { role: "party"; stage: OnCallStage; dialId?: DialId }
|
|
625
626
|
| { role: "agent"; userId: UserId }
|
|
626
627
|
| { role: "consulted" | "conferenced"; destinationId: string; stage: OnCallStage; dialId?: DialId; label?: string }
|
|
627
628
|
);
|
|
@@ -1943,7 +1944,7 @@ Creates one live provider connection for the signed-in agent.
|
|
|
1943
1944
|
| --- | --- |
|
|
1944
1945
|
| `protocolVersion` | Version negotiated before authentication. Fixed for this login. |
|
|
1945
1946
|
| `loginId` | Omni-generated identity for this login. It is the same value passed as `AuthenticationContext.loginId`, so an adapter can correlate this connection with the session that authenticated it. Stable across transport reconnects and changed only by a new login. |
|
|
1946
|
-
| `autoAcceptTasks` | Agent provisioning policy relayed to the provider at login
|
|
1947
|
+
| `autoAcceptTasks` | Agent provisioning policy relayed to the provider at login, stated by the host on every connection and never assumed from its absence. When `true`, a pending task states its `acceptance`; when `false`, every task requires agent acceptance. Fixed for this connection, like everything else here: the provider states or omits `acceptance` by the value it was sent, and Omni validates by that same value, not by a policy that has since moved — a change reaches the provider through a fresh `connect()`. |
|
|
1947
1948
|
| `timeZone` | The same value passed as `AuthenticationContext.timeZone`. The provider stores it on the agent and carries it on the identity. See **The agent's day**. |
|
|
1948
1949
|
| `phone` | The same value passed as `AuthenticationContext.phone`: how this login hears its calls. The type ties `host.mute` to it: a `softphone` login's host states what its Mute does, and a desk-phone or conversation login's host cannot, so the omission is a compile error rather than a live seat's discovery. See **The station is the host's**. |
|
|
1949
1950
|
| `store` | The login's operational store, kept by the host for the life of the login, across a reload of the host, and cleared at sign-out: where an adapter that composes a record keeps what its platform cannot hold for it, such as the handling legs a host reported. Three functions, by key. Never for anything sensitive, which is `AuthenticationContext.secrets`, a store a host may clear aggressively. **A task's keys carry the task id and go with the task**: a key written about a task names the task's id in the key, and is deleted before the task's end is published, because a platform retires a task id minutes after closing it and a requeue takes seconds, so a key that outlives its task is inherited by the next offer of the same id -- a record with legs the host never reported against it. A login-scoped key carries no task id and outlives any task. The harness requires the store of every connection (`store.shape`, `store.get`, `.set`, `.delete`), watches the one it hands over, and names a task's key still held after `task-ended` (`drive.store.retained`) or written about the task after its end -- a persist hung off a timer that saw the task as it was (`drive.store.late`). The ordinary late write is the host's, not the adapter's: a host reports a leg without waiting for the answer, so an unmute can follow `complete` by a tick, and the adapter answers a report about a task that has ended `failed` with `omni.task-not-found` and writes nothing -- the host ends its own open legs at the task's end, so such a report is the host's error to see, and a host reads every `recordStep` answer and awaits the one for the leg it closes at a task's end, the only moment a refusal is expected, since a report nobody waits for is an error nobody can see; an adapter that names no task in its keys gets no cleanup check, which is a gap rather than a pass, never an exemption: the obligation is that nothing of a closed task survives its ending, and an adapter that keeps every open task in one login-scoped value owes exactly that inside the value, where the harness cannot look. One key per task, named for it, is the shape the harness can hold, and the shape to reach for. The store lists nothing, so an adapter that needs to find its tasks keeps a login-scoped index of ids beside them; at the task's end it deletes the body first and reindexes after, since a crash between the two then leaves an index naming a task with no body, which a reader skips, where the other order leaves a body for a task that has ended, which is the hazard itself. A reader of the index tolerates an id with no body as an ending that was underway, not as corruption. |
|
|
@@ -2144,12 +2145,11 @@ provider's requirement, stated on a wire where Omni was willing to accept for th
|
|
|
2144
2145
|
no-auto-accept policy puts no word on the wire at all — the field is absent, and the **Accept**
|
|
2145
2146
|
press is Omni's doing, not the provider's.
|
|
2146
2147
|
|
|
2147
|
-
**
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
it: `consent` puts the decision back in the agent's hands for any task where it
|
|
2148
|
+
**The value is stated, never assumed.** `autoAcceptTasks` is required of every connection: a
|
|
2149
|
+
host says which policy the deployment provisioned, and a validator that is not told checks
|
|
2150
|
+
neither rule rather than guess the permissive one (`task.acceptance.required` and
|
|
2151
|
+
`.unexpected` fire only against a stated value). `acceptance` is the provider's own control and
|
|
2152
|
+
outranks the policy: `consent` puts the decision back in the agent's hands for any task where it
|
|
2153
2153
|
belongs, whatever the host was configured with.
|
|
2154
2154
|
|
|
2155
2155
|
An automatically accepted task still arrives through `task-offered`.
|
|
@@ -2230,7 +2230,7 @@ time. Runtime conformance checks also require the task channel to match its prov
|
|
|
2230
2230
|
| `browsers` | Named browser definitions for the task workspace: at least one when the task declares the `browsers` capability, empty when it does not. |
|
|
2231
2231
|
| `party` | The person or entity on the other end of this task, as a `Contact`: often a name and one address; a withheld caller ID may leave nothing to send at all. Optional. The party is who the task is *with*; `contacts` is the directory. |
|
|
2232
2232
|
| `phase` | Current canonical task phase: `pending`, `confirmed`, `preview`, `in-progress`, `paused`, or `completing`. `preview` is voice only. |
|
|
2233
|
-
| `media` | Voice only. The task's real-time audio as the provider holds it: `started` while audio is attached, `ended` once it ended, omitted while none is. The provider's word — see **`task-media-started`**. |
|
|
2233
|
+
| `media` | Voice only. The task's real-time audio as the provider holds it: `started` while audio is attached, `ended` once it ended, omitted while none is. The provider's word — see **`task-media-started`**. Media names a task whose work has begun: on a `pending`, `confirmed` or `preview` task it is refused (`task.media.beforeWork`), on a snapshot as on the event, since a host opens the microphone on it. |
|
|
2234
2234
|
| `acceptance` | How this offer is accepted — `no-preference`, `consent`, or `automatic` — stated on the pending task so a reconnect snapshot says it too. Required while `pending` when `autoAcceptTasks` was `true`, forbidden when it was `false`, and absent past `pending`. See **Acceptance modes**. |
|
|
2235
2235
|
| `previewEndsAt` | Voice only, in `preview`: when the system stops waiting for the agent to press Call. Absent, the agent has as long as they need. Always with `atDeadline`. See **Preview: the agent presses Call**. |
|
|
2236
2236
|
| `atDeadline` | Voice only, in `preview`, with `previewEndsAt`: what the system does at the deadline -- `calls` places the call itself, `expires` takes the record back and the task ends `expired`. |
|
|
@@ -2239,7 +2239,7 @@ time. Runtime conformance checks also require the task channel to match its prov
|
|
|
2239
2239
|
| `wrapAllowance` | Fixed time allowed to complete the task after primary handling ends. For real-time media, it begins after `task-media-ended`. Required under `provider-automatic`, where the provider acts on it. Optional under `agent-command`: omitted says the provider imposes no deadline, and Omni counts nothing down. |
|
|
2240
2240
|
| `attributes` | Optional ordered, typed `TaskAttribute` entries with keys unique within the task. Each contact or timestamp is a separate array item; new attribute shapes require new union members. |
|
|
2241
2241
|
| `handlingHistory` | The call record: `steps` — the ordered handling history of this open task, one entry per occurrence, oldest first — and what they add up to before this agent, `handleSeconds`, `holdSeconds`, `queueSeconds`, `transfers`, each present when the provider knows it. Live task data restated with the task, not a permanent archive. See **How a task has been handled**. |
|
|
2242
|
-
| `onCall` | Voice only. Who is on the call, or being brought onto it, as the provider states it, replaced whole with the task: `party` is the customer, `agent` a person by user id, `consulted` and `conferenced` somebody a dial is bringing in, listed from the moment the dial is placed -- with the `destinationId` dialled, the `dialId` where a host placed it, the `stage` reached (`ringing` until answered, `joined` after), and `held: true` on anyone joined and parked. A `consulted` entry is what makes `transfer` `complete` and `cancel` issuable. `label` names a destination -- a person, a queue -- not a phrase; the host supplies the verb. Present when the provider knows the room, absent when it does not. See **Every dial has an outcome**. |
|
|
2242
|
+
| `onCall` | Voice only. Who is on the call, or being brought onto it, as the provider states it, replaced whole with the task: `party` is the customer -- carrying a `stage` while being dialled again on the same task, a connect-back with the host's `dialId` or a platform's callback without, ringing from the moment the dial is placed and joined on its answered outcome --, `agent` a person by user id, `consulted` and `conferenced` somebody a dial is bringing in, listed from the moment the dial is placed -- with the `destinationId` dialled, the `dialId` where a host placed it, the `stage` reached (`ringing` until answered, `joined` after), and `held: true` on anyone joined and parked. A `consulted` entry is what makes `transfer` `complete` and `cancel` issuable. `label` names a destination -- a person, a queue -- not a phrase; the host supplies the verb. Present when the provider knows the room, absent when it does not. See **Every dial has an outcome**. |
|
|
2243
2243
|
| `leadAssist` | Voice only. Present from the agent's request for a lead until the lead leaves or the request ends: `requested` while nobody has joined, `joined` with the lead's `leadId` once somebody has. See **Lead assist**. |
|
|
2244
2244
|
| `assisting` | Voice only, on the lead's own task for a call they joined: which member asked, with their note. Its presence is what makes `lead-assist` `take-over` and `leave` issuable. See **Lead assist**. |
|
|
2245
2245
|
| `monitoring` | Voice only, on the lead's own task while they listen to a member's call: whose call, which call, and the `mode` they are heard in, restated on every change. Never on the member's task, and never together with `assisting`. See **Monitoring a call**. |
|
|
@@ -2422,6 +2422,16 @@ whose outcome says so and a call whose media ended: the task returns to `complet
|
|
|
2422
2422
|
same event and the clock starts again from there. At no point is an agent dialling against a
|
|
2423
2423
|
deadline.
|
|
2424
2424
|
|
|
2425
|
+
**The room shows the party being dialled again.** From the moment the connect-back is placed the
|
|
2426
|
+
`party` entry carries `stage: "ringing"` and the host's `dialId`, exactly as a consulted or
|
|
2427
|
+
conferenced entry does from its dial, and `joined` on the answered `dial-outcome` and never before
|
|
2428
|
+
it (`stream.taskUpdated.stage`); a host shows a call being placed, not a party it asserts is on a
|
|
2429
|
+
call that is still ringing. A callback the platform places itself on the same task -- the first
|
|
2430
|
+
call over, the platform dialling the party again without a command -- shows the same thing with no
|
|
2431
|
+
host `dialId`, as any platform-placed dial does. A party with no stage is on the call, and a dial
|
|
2432
|
+
with no stage is half a claim (`task.onCall.party.dial`). The stage is also what lets the stream
|
|
2433
|
+
tell a task coming back from one going backwards (`stream.taskUpdated.phase`).
|
|
2434
|
+
|
|
2425
2435
|
**The control exists only while there is a window to use it in.** Under `agent-command` the task
|
|
2426
2436
|
stays `completing` until the agent completes it, so the window is open for as long as they need.
|
|
2427
2437
|
Under `provider-automatic` the window is the allowance -- and with `wrapAllowance: 0` there
|
|
@@ -2500,7 +2510,10 @@ entries, one per join. Order is enforced: an entry earlier than the one before i
|
|
|
2500
2510
|
total the provider adds: *time to offer*, from the call's arrival to the agent's screen lighting
|
|
2501
2511
|
up, is the `offered` entry's `at` minus the last `queued` entry's, and the ring is outside it.
|
|
2502
2512
|
`queueSeconds` keeps the industry's meaning -- the whole wait until somebody answered -- and is not
|
|
2503
|
-
that interval.
|
|
2513
|
+
that interval. **The second is the record's grain.** A duration is a positive whole number of
|
|
2514
|
+
seconds, and nought is refused because it claims no time; a leg that happened is stated at the
|
|
2515
|
+
grain: rounded to the nearest second, and a leg that rounds to nought is stated as `1`, the error
|
|
2516
|
+
being under the contract's resolution, where dropping the entry would say the leg never happened.
|
|
2504
2517
|
|
|
2505
2518
|
**A record once read is not unread.** Every restatement of a task's record -- on `task-updated`,
|
|
2506
2519
|
on a resync snapshot -- carries every entry the host has already read, by step and instant, and
|
|
@@ -2795,7 +2808,17 @@ Terms once read stay read: a re-read that fails mid-task is not a new fact about
|
|
|
2795
2808
|
last statement stands and the failure is a `diagnostic`, and a task that was published under
|
|
2796
2809
|
`queue` or `ungoverned` never returns to `undetermined`, on an update (`stream.taskUpdated.capabilitySource`)
|
|
2797
2810
|
or on a resync snapshot (`stream.snapshot.capabilitySource`); a record once read never loses an
|
|
2798
|
-
entry the same two ways (`stream.taskUpdated.handlingHistory`, `stream.snapshot.handlingHistory`)
|
|
2811
|
+
entry the same two ways (`stream.taskUpdated.handlingHistory`, `stream.snapshot.handlingHistory`);
|
|
2812
|
+
and a task does not go backwards (`stream.taskUpdated.phase`): the stream sees publications, not
|
|
2813
|
+
transitions, and a task may pass through a phase between two, so `pending` to `in-progress` stands
|
|
2814
|
+
with `confirmed` between them, and what is refused is a phase unreachable from the last one read by
|
|
2815
|
+
the transition table -- back to `pending`, back to `confirmed` or `preview` once work began, out of
|
|
2816
|
+
`completing` except by the party being dialled again, a connect-back or a platform's callback,
|
|
2817
|
+
which the update itself shows: the party on the call carrying a `stage`. A completing task
|
|
2818
|
+
republished as `in-progress` from a stale copy carries no such stage, and that is the ending the
|
|
2819
|
+
agent never saw. Audio arrives only on a task at work: `task-media-started` on a `completing` task
|
|
2820
|
+
is refused as it is on a pending one (`stream.taskMediaStarted.beforeWork`), since a connect-back
|
|
2821
|
+
returns the task to `in-progress` before any media.
|
|
2799
2822
|
|
|
2800
2823
|
What the agent is told differs by source, and only one source tells them anything. Under `queue`
|
|
2801
2824
|
and `ungoverned` the agent sees controls and nothing about where they came from: both are facts,
|
|
@@ -2850,8 +2873,10 @@ See **Which commands need a capability**.
|
|
|
2850
2873
|
|
|
2851
2874
|
### Publishing codes and destinations
|
|
2852
2875
|
|
|
2853
|
-
Four capabilities accept an object
|
|
2854
|
-
|
|
2876
|
+
Four capabilities accept an object when the provider wants Omni to render real choices. For
|
|
2877
|
+
`dispositions` alone, `true` remains valid and means "offer the control with nothing published";
|
|
2878
|
+
the three directory controls -- `coldTransfer`, `warmTransfer`, `conference` -- carry their
|
|
2879
|
+
directory or are refused, since once nothing is typed the directory is the control.
|
|
2855
2880
|
|
|
2856
2881
|
#### `dispositions`
|
|
2857
2882
|
|
|
@@ -3698,7 +3723,7 @@ choice that is no command at all:
|
|
|
3698
3723
|
|
|
3699
3724
|
| The lead | The agent's task | The lead's task |
|
|
3700
3725
|
| --- | --- | --- |
|
|
3701
|
-
| `{ type: "lead-assist", action: "take-over" }` | `task-ended` with `{ type: "transferred",
|
|
3726
|
+
| `{ type: "lead-assist", action: "take-over" }` | `task-ended` with `{ type: "transferred", destinationId: leadId }`, straight from `in-progress`: **no `completing` window**, the agent is idle at once | Continues alone, and ends as any call does |
|
|
3702
3727
|
| `{ type: "lead-assist", action: "leave" }` | Continues; `leadAssist` is cleared | `task-ended` with `{ type: "left" }` -- the call goes on without them |
|
|
3703
3728
|
| Stays until the customer hangs up | `task-media-ended`, `completing`, its own disposition | The same, independently: **both have the disposal window** |
|
|
3704
3729
|
|
|
@@ -4134,12 +4159,14 @@ declared:
|
|
|
4134
4159
|
| `conference` with `action: "remove"` | The `conference` capability, and somebody else on the call: a remove that would leave the agent alone is `end-call`, and a provider answers it `failed`. |
|
|
4135
4160
|
| `decline` | The `decline` capability on any channel, **and** Omni provisioning permitting it. One word for refusing an offer, whatever the channel. |
|
|
4136
4161
|
| `call` | The `preview` phase. A record put in front of an agent is there to be called, so the phase is the gate and there is no capability. It is a dial, with a `dialId` and a `dial-outcome`. |
|
|
4137
|
-
| `complete` | `completionMode: "agent-command"`. The `dispositions` capability decides whether a code travels with the command, never whether the command exists — a task Omni cannot complete never ends. |
|
|
4162
|
+
| `complete` | `completionMode: "agent-command"`. The `dispositions` capability decides whether a code travels with the command, never whether the command exists — a task Omni cannot complete never ends. What travels is what the capability published: a code from its list where it has one (`command.complete.disposition.unknown`), a code at all where it requires one (`.disposition.required`), notes as it said (`.notes.required`, `.notes.unexpected`), and neither where the task declares no dispositions (`.disposition.unexpected`). |
|
|
4138
4163
|
| `connect-back` | The `connectBack` capability **and** the `completing` phase. It exists to reach the party again after the call, so it has no meaning while the call is up. |
|
|
4139
4164
|
| `transfer` with `action: "warm"` | The `warmTransfer` capability. `action: "cold"` is gated by `coldTransfer`; the two are declared and offered separately. |
|
|
4140
4165
|
| `transfer` with `action: "complete"` or `"cancel"` | A consultation in progress -- a `consulted` entry on `Task.onCall`. Without one there is nothing to complete or cancel, and a provider that receives either answers `failed`. |
|
|
4141
4166
|
| `lead-assist` with `action: "request"` or `"cancel"` | The `leadAssist` capability. `cancel` needs a request standing -- `Task.leadAssist` with status `requested`. |
|
|
4142
4167
|
| `lead-assist` with `action: "take-over"` or `"leave"` | The lead's own task, on a call they joined -- `Task.assisting` present. An agent's task never has it, and a provider that receives either without it answers `failed`. |
|
|
4168
|
+
| `transfer` with a destination, `conference` with `action: "add"` | Its capability, and a `destinationId` the directory offered: the id Omni sends is the id the provider published (`command.destination.unknown`). |
|
|
4169
|
+
| `custom` | A control the task published under `capabilities.custom`, by its `id` (`command.capability.custom`), carrying every field the control's `prompt` asked for (`command.custom.prompt`). |
|
|
4143
4170
|
| Everything else | Its own named capability. |
|
|
4144
4171
|
|
|
4145
4172
|
**A control on the contact belongs to the handling phases**, `in-progress` and `paused`:
|
|
@@ -4150,7 +4177,7 @@ call with nobody on it, a conversation closed -- and a wrap-up that still shows
|
|
|
4150
4177
|
for nothing. The capability stays declared, because it is a property of the task and the task is
|
|
4151
4178
|
still open; the phase says there is nothing to use it on. Omni shows none of these controls outside
|
|
4152
4179
|
the two phases, and `validateTaskCommand` refuses each of them there (`command.phase.handling`).
|
|
4153
|
-
The commands with a phase of their own -- `answer`, `accept
|
|
4180
|
+
The commands with a phase of their own -- `answer`, `accept` and `decline` in `pending`,
|
|
4154
4181
|
`call` in `preview`, `connect-back` in `completing`, `complete` in any -- are not among them.
|
|
4155
4182
|
|
|
4156
4183
|
`validateTaskCommand(command, task)` holds a command to this table at runtime, both ways: the
|
|
@@ -4594,7 +4621,18 @@ complete it with a disposition where the agent completes -- and holds every step
|
|
|
4594
4621
|
host holds a provider to. Each command is validated against the task as published
|
|
4595
4622
|
(`drive.command.*`), each answer for its method, a refusal of a control the task offered is a
|
|
4596
4623
|
violation (`drive.command.failed`), and an event the provider owes and never sends is one too
|
|
4597
|
-
(`drive.timeout`, after `driveTimeoutMs`, 5000 by default).
|
|
4624
|
+
(`drive.timeout`, after `driveTimeoutMs`, 5000 by default). Around the drive the exercise holds
|
|
4625
|
+
the run to what a host holds a provider to between commands: an offer before the host stated
|
|
4626
|
+
capacity, or beyond the count with nothing dialled on it, is named
|
|
4627
|
+
(`stream.taskOffered.beforeCapacity`, `.overCapacity`); every user the snapshot names is looked up
|
|
4628
|
+
through `describeUsers` and the answer held to the shape, nobody unasked, nobody described as
|
|
4629
|
+
nothing (`describeUsers.user.*`, `describeUsers.unasked`, `connection.describeUsers.empty`); and
|
|
4630
|
+
the second adapter a `rebuild` gives comes up signed in as the same login from the secrets alone
|
|
4631
|
+
before it reads anything (`drive.reload.login`). The result also says which rules the
|
|
4632
|
+
run evaluated, pass or fail, in `rulesEvaluated`: the validators' as each was applied, the stream's
|
|
4633
|
+
as each case was considered, so a test that needs a rule to have run asserts it there rather than
|
|
4634
|
+
inferring it from an empty `violations`, and a rule absent from it was never looked at, which is a
|
|
4635
|
+
gap and not a pass. With the audio open on a softphone,
|
|
4598
4636
|
the drive mutes it for one second and reports the leg through `recordStep`, begun and then ended,
|
|
4599
4637
|
expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); and where the provider
|
|
4600
4638
|
restates the task's record afterwards, the leg is in it or the hole is named
|