@xema/omni-protocol 0.1.15 → 0.1.17
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 +22 -1
- package/dist/testing.d.ts +14 -0
- package/dist/testing.js +151 -6
- package/dist/validation.d.ts +10 -0
- package/dist/validation.js +147 -10
- package/guide.md +76 -19
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -247,12 +247,32 @@ export interface AuthenticationSession {
|
|
|
247
247
|
signOut(): Promise<AuthenticationActionResult>;
|
|
248
248
|
close(): Promise<void>;
|
|
249
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* How the host's own audio stands: the microphone Omni captures for the agent, and whether it has
|
|
252
|
+
* it. Omni facilitates the microphone -- captures it, prompts, retries, tells the agent -- and
|
|
253
|
+
* does not decide for the adapter what a missing one means. The adapter does what its platform
|
|
254
|
+
* needs: go not-ready, refuse calls, or carry on because audio lands elsewhere.
|
|
255
|
+
*/
|
|
256
|
+
export type HostMediaState = {
|
|
257
|
+
status: "ready";
|
|
258
|
+
localAudio: MediaStream;
|
|
259
|
+
} | {
|
|
260
|
+
status: "unavailable";
|
|
261
|
+
failure: ProtocolFailure;
|
|
262
|
+
};
|
|
263
|
+
/** The host's audio, observable for the life of the connection, as authentication is. */
|
|
264
|
+
export interface HostMedia {
|
|
265
|
+
state(): HostMediaState;
|
|
266
|
+
subscribe(listener: (state: HostMediaState) => void): Unsubscribe;
|
|
267
|
+
}
|
|
250
268
|
export interface ConnectContext {
|
|
251
269
|
protocolVersion: number;
|
|
252
270
|
/** The session that authenticated this connection. */
|
|
253
271
|
sessionId: string;
|
|
254
272
|
/** Omni-side policy: whether the agent's tasks are accepted without asking them. */
|
|
255
273
|
autoAcceptTasks?: boolean;
|
|
274
|
+
/** The host's audio. Present on a voice connection; absent on a channel with no media. */
|
|
275
|
+
media?: HostMedia;
|
|
256
276
|
signal?: AbortSignal;
|
|
257
277
|
log?: (entry: unknown) => void;
|
|
258
278
|
}
|
|
@@ -791,7 +811,8 @@ export interface VoiceMediaSession {
|
|
|
791
811
|
}
|
|
792
812
|
export interface OpenMediaRequest {
|
|
793
813
|
taskId: TaskId;
|
|
794
|
-
|
|
814
|
+
/** The agent's microphone as Omni captured it; absent while `HostMediaState` is `unavailable`. */
|
|
815
|
+
localAudio?: MediaStream;
|
|
795
816
|
}
|
|
796
817
|
export type OpenMediaResult = {
|
|
797
818
|
status: "opened";
|
package/dist/testing.d.ts
CHANGED
|
@@ -95,6 +95,20 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
|
|
|
95
95
|
* both.
|
|
96
96
|
*/
|
|
97
97
|
export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
|
|
98
|
+
/** What a stream has said about the tasks it carries, and the rules across events. */
|
|
99
|
+
export declare class TaskStream {
|
|
100
|
+
private readonly tasks;
|
|
101
|
+
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
102
|
+
seed(snapshot: unknown): void;
|
|
103
|
+
/** Applies one envelope and returns what it may not say given what came before. */
|
|
104
|
+
apply(envelope: unknown, path?: string): ProtocolViolation[];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The media follows the task and never decides it. Given a provider's stream -- optionally seeded
|
|
108
|
+
* with the snapshot it began from -- every task is introduced once, `task-media-ended` names a
|
|
109
|
+
* task whose work has begun, and what follows it is `completing` or `task-ended`.
|
|
110
|
+
*/
|
|
111
|
+
export declare function assertMediaFollowsTheTask(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
|
|
98
112
|
/** One provider as the host sees it when freezing a break attempt's participant set. */
|
|
99
113
|
export interface BreakCandidate {
|
|
100
114
|
id: string;
|
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { browserSessionKey, sameCapabilities, } from "./index.js";
|
|
2
|
-
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateManifest, validateResult, validateSnapshot, } from "./validation.js";
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostMediaState, validateManifest, validateResult, validateSnapshot, } 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
|
|
@@ -59,8 +59,11 @@ function observeTask(value, seen) {
|
|
|
59
59
|
const capabilities = isRecord(value.capabilities) ? value.capabilities : {};
|
|
60
60
|
if (isRecord(capabilities.dispositions))
|
|
61
61
|
seen.add("task.dispositions");
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
for (const directory of ["blindTransfer", "consultTransfer", "conference"]) {
|
|
63
|
+
const declared = capabilities[directory];
|
|
64
|
+
if (isRecord(declared) && some(declared.destinations))
|
|
65
|
+
seen.add("task.destinations");
|
|
66
|
+
}
|
|
64
67
|
if (some(capabilities.custom))
|
|
65
68
|
seen.add("task.custom");
|
|
66
69
|
}
|
|
@@ -139,6 +142,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
139
142
|
const violations = [...validateManifest(adapter.manifest)];
|
|
140
143
|
const events = [];
|
|
141
144
|
const seen = new Set();
|
|
145
|
+
const stream = new TaskStream();
|
|
146
|
+
let seeded = false;
|
|
142
147
|
const storedSecrets = new Map();
|
|
143
148
|
const authentication = await adapter.createAuthenticationSession({
|
|
144
149
|
...context,
|
|
@@ -151,6 +156,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
151
156
|
let connection;
|
|
152
157
|
let unsubscribe;
|
|
153
158
|
let unsubscribeAuthentication;
|
|
159
|
+
let unsubscribeMedia;
|
|
154
160
|
let authenticationState;
|
|
155
161
|
let login;
|
|
156
162
|
let disconnectWasClean = false;
|
|
@@ -173,7 +179,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
173
179
|
throw new Error("unreachable: the exercise has an authenticated login");
|
|
174
180
|
return login;
|
|
175
181
|
};
|
|
176
|
-
const reader = () => ({
|
|
182
|
+
const reader = () => ({
|
|
183
|
+
self: current().identity.id,
|
|
184
|
+
capabilities: current().capabilities,
|
|
185
|
+
sessionId: context.sessionId,
|
|
186
|
+
autoAcceptTasks: context.autoAcceptTasks ?? true,
|
|
187
|
+
});
|
|
177
188
|
// The optional methods are optional only until something declares a need for them. Each
|
|
178
189
|
// check pairs a method with the declaration that requires it, as the guide's Live-connection
|
|
179
190
|
// table does; a missing one is a control the agent would be shown and could never use.
|
|
@@ -216,6 +227,14 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
216
227
|
requireCapabilityMethods(connection, state.capabilities);
|
|
217
228
|
}
|
|
218
229
|
});
|
|
230
|
+
// The host's media is Omni's output, and a test that hands the adapter a malformed one is
|
|
231
|
+
// testing a host that cannot exist. Its first state and every later one are validated.
|
|
232
|
+
if (context.media !== undefined) {
|
|
233
|
+
violations.push(...validateHostMediaState(context.media.state(), "context.media"));
|
|
234
|
+
unsubscribeMedia = context.media.subscribe(state => {
|
|
235
|
+
violations.push(...validateHostMediaState(state, "context.media"));
|
|
236
|
+
});
|
|
237
|
+
}
|
|
219
238
|
connection = await adapter.connect(context);
|
|
220
239
|
const live = connection;
|
|
221
240
|
// Dial is declared by presence: the capability object carries a destination policy rather
|
|
@@ -229,6 +248,11 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
229
248
|
unsubscribe = connection.subscribe(envelope => {
|
|
230
249
|
observeEvent(envelope, seen);
|
|
231
250
|
violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader()));
|
|
251
|
+
if (eventNamesUsers(envelope))
|
|
252
|
+
requireMethod(live, "describeUsers", "an event publishes a UserId");
|
|
253
|
+
// Cross-event rules apply once the stream has a beginning: the connect snapshot.
|
|
254
|
+
if (seeded)
|
|
255
|
+
violations.push(...stream.apply(envelope));
|
|
232
256
|
if (typeof envelope?.id === "string") {
|
|
233
257
|
if (eventIds.has(envelope.id))
|
|
234
258
|
return;
|
|
@@ -239,6 +263,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
239
263
|
const snapshot = await connection.snapshot();
|
|
240
264
|
observeSnapshot(snapshot, seen);
|
|
241
265
|
violations.push(...validateSnapshot(snapshot, adapter.manifest, "snapshot", reader()));
|
|
266
|
+
stream.seed(snapshot);
|
|
267
|
+
seeded = true;
|
|
242
268
|
requireCapabilityMethods(live, current().capabilities);
|
|
243
269
|
if (publishesUserIds(snapshot))
|
|
244
270
|
requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
|
|
@@ -270,6 +296,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
270
296
|
catch {
|
|
271
297
|
clean = false;
|
|
272
298
|
}
|
|
299
|
+
try {
|
|
300
|
+
unsubscribeMedia?.();
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
clean = false;
|
|
304
|
+
}
|
|
273
305
|
try {
|
|
274
306
|
await connection?.disconnect();
|
|
275
307
|
}
|
|
@@ -310,11 +342,29 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
310
342
|
function publishesUserIds(snapshot) {
|
|
311
343
|
if (snapshot?.break?.imposed?.by !== undefined)
|
|
312
344
|
return true;
|
|
313
|
-
if (
|
|
345
|
+
if (teamNamesUsers(snapshot?.team))
|
|
314
346
|
return true;
|
|
315
347
|
if (!Array.isArray(snapshot?.tasks))
|
|
316
348
|
return false;
|
|
317
|
-
return snapshot.tasks.some(
|
|
349
|
+
return snapshot.tasks.some(taskNamesUsers);
|
|
350
|
+
}
|
|
351
|
+
const teamNamesUsers = (team) => isRecord(team) && (some(team.members) || some(team.requests));
|
|
352
|
+
const taskNamesUsers = (task) => isRecord(task) && ((Array.isArray(task.handlingHistory) && task.handlingHistory.some(step => isRecord(step) && step.by !== undefined)) ||
|
|
353
|
+
(isRecord(task.lead) && task.lead.leadId !== undefined) ||
|
|
354
|
+
isRecord(task.assisting));
|
|
355
|
+
/** Whether an event publishes a `UserId`, on a roster, a task, or the snapshot a reconnect carries. */
|
|
356
|
+
function eventNamesUsers(envelope) {
|
|
357
|
+
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
358
|
+
if (!isRecord(event))
|
|
359
|
+
return false;
|
|
360
|
+
switch (event.type) {
|
|
361
|
+
case "snapshot": return publishesUserIds(event.snapshot);
|
|
362
|
+
case "break-state": return isRecord(event.break) && isRecord(event.break.imposed) && event.break.imposed.by !== undefined;
|
|
363
|
+
case "task-offered":
|
|
364
|
+
case "task-updated": return taskNamesUsers(event.task);
|
|
365
|
+
case "team-updated": return teamNamesUsers(event.team);
|
|
366
|
+
default: return false;
|
|
367
|
+
}
|
|
318
368
|
}
|
|
319
369
|
/**
|
|
320
370
|
* `refreshing` carries the identity and capabilities of the login it refreshes. A change to
|
|
@@ -490,6 +540,101 @@ export function assertDeniedAndRetriedBreak(approvals) {
|
|
|
490
540
|
throw new Error(`Break retry scenario must end granted or in effect, ended ${String(last)}`);
|
|
491
541
|
}
|
|
492
542
|
}
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
// The task stream. Each event is validated on its own; what one event may say about a task
|
|
545
|
+
// depends on what was said before, and only something that watched the whole stream can hold a
|
|
546
|
+
// provider to it. A task is never its audio: media ends only on work that has begun, and what
|
|
547
|
+
// follows the media ending is the work completing or ending, never a phase the audio decided.
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
const WORK_BEGUN = new Set(["in-progress", "paused", "completing"]);
|
|
550
|
+
/** What a stream has said about the tasks it carries, and the rules across events. */
|
|
551
|
+
export class TaskStream {
|
|
552
|
+
tasks = new Map();
|
|
553
|
+
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
554
|
+
seed(snapshot) {
|
|
555
|
+
this.tasks.clear();
|
|
556
|
+
if (!isRecord(snapshot) || !Array.isArray(snapshot.tasks))
|
|
557
|
+
return;
|
|
558
|
+
for (const task of snapshot.tasks) {
|
|
559
|
+
if (isRecord(task) && typeof task.id === "string")
|
|
560
|
+
this.tasks.set(task.id, { phase: String(task.phase), mediaEnded: false });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/** Applies one envelope and returns what it may not say given what came before. */
|
|
564
|
+
apply(envelope, path = "event") {
|
|
565
|
+
const found = [];
|
|
566
|
+
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
567
|
+
if (!isRecord(event))
|
|
568
|
+
return found;
|
|
569
|
+
const at = `${path}.event`;
|
|
570
|
+
const refuse = (rule, where, message) => found.push({ rule, path: where, message });
|
|
571
|
+
const id = typeof event.taskId === "string" ? event.taskId : isRecord(event.task) && typeof event.task.id === "string" ? event.task.id : undefined;
|
|
572
|
+
const known = id === undefined ? undefined : this.tasks.get(id);
|
|
573
|
+
switch (event.type) {
|
|
574
|
+
case "snapshot":
|
|
575
|
+
this.seed(event.snapshot);
|
|
576
|
+
break;
|
|
577
|
+
case "task-offered":
|
|
578
|
+
if (id === undefined)
|
|
579
|
+
break;
|
|
580
|
+
if (known !== undefined)
|
|
581
|
+
refuse("stream.taskOffered.duplicate", `${at}.task.id`, `${id} is already on the stream; an offer introduces a task once`);
|
|
582
|
+
this.tasks.set(id, { phase: String(isRecord(event.task) ? event.task.phase : undefined), mediaEnded: false });
|
|
583
|
+
break;
|
|
584
|
+
case "task-updated":
|
|
585
|
+
if (id === undefined)
|
|
586
|
+
break;
|
|
587
|
+
if (known === undefined) {
|
|
588
|
+
refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
if (known.mediaEnded) {
|
|
592
|
+
const phase = isRecord(event.task) ? String(event.task.phase) : "";
|
|
593
|
+
if (phase !== "completing") {
|
|
594
|
+
refuse("stream.taskMediaEnded.follow", `${at}.task.phase`, `after its media ended, ${id} completes or ends; ${phase} is a phase the audio does not decide`);
|
|
595
|
+
}
|
|
596
|
+
known.mediaEnded = phase === "completing" ? false : known.mediaEnded;
|
|
597
|
+
}
|
|
598
|
+
known.phase = isRecord(event.task) ? String(event.task.phase) : known.phase;
|
|
599
|
+
break;
|
|
600
|
+
case "task-media-ended":
|
|
601
|
+
if (id === undefined)
|
|
602
|
+
break;
|
|
603
|
+
if (known === undefined) {
|
|
604
|
+
refuse("stream.taskMediaEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
if (!WORK_BEGUN.has(known.phase)) {
|
|
608
|
+
refuse("stream.taskMediaEnded.beforeWork", `${at}.taskId`, `media cannot end on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
609
|
+
}
|
|
610
|
+
known.mediaEnded = true;
|
|
611
|
+
break;
|
|
612
|
+
case "task-ended":
|
|
613
|
+
if (id === undefined)
|
|
614
|
+
break;
|
|
615
|
+
if (known === undefined)
|
|
616
|
+
refuse("stream.taskEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
617
|
+
this.tasks.delete(id);
|
|
618
|
+
break;
|
|
619
|
+
default:
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
return found;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* The media follows the task and never decides it. Given a provider's stream -- optionally seeded
|
|
627
|
+
* with the snapshot it began from -- every task is introduced once, `task-media-ended` names a
|
|
628
|
+
* task whose work has begun, and what follows it is `completing` or `task-ended`.
|
|
629
|
+
*/
|
|
630
|
+
export function assertMediaFollowsTheTask(envelopes, snapshot) {
|
|
631
|
+
const stream = new TaskStream();
|
|
632
|
+
if (snapshot !== undefined)
|
|
633
|
+
stream.seed(snapshot);
|
|
634
|
+
const found = [];
|
|
635
|
+
envelopes.forEach((envelope, index) => found.push(...stream.apply(envelope, `envelopes[${index}]`)));
|
|
636
|
+
assertNoViolations(found, "The media follows the task");
|
|
637
|
+
}
|
|
493
638
|
const usableLogin = (status) => status === "authenticated" || status === "refreshing";
|
|
494
639
|
/**
|
|
495
640
|
* The participant set of a break attempt is every connected provider from which the agent can
|
package/dist/validation.d.ts
CHANGED
|
@@ -30,10 +30,20 @@ export interface ReaderContext {
|
|
|
30
30
|
* snapshot carries a roster, nobody else's does, and `requests` need `team.consultControl`.
|
|
31
31
|
*/
|
|
32
32
|
capabilities?: SessionCapabilities;
|
|
33
|
+
/** The login's `sessionId`. A snapshot or event naming another belongs to a login that is gone. */
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
/** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether `task-offered` carries an `acceptanceMode`. */
|
|
36
|
+
autoAcceptTasks?: boolean;
|
|
33
37
|
}
|
|
34
38
|
export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
35
39
|
export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
36
40
|
export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
41
|
+
/**
|
|
42
|
+
* Validates the host's media state as Omni publishes it to an adapter. This is Omni's output, so
|
|
43
|
+
* the check belongs to the host's own tests and to the harness, which validates whatever media a
|
|
44
|
+
* test hands the adapter.
|
|
45
|
+
*/
|
|
46
|
+
export declare function validateHostMediaState(state: unknown, path?: string): ProtocolViolation[];
|
|
37
47
|
/** The connection methods whose results `validateResult` knows. */
|
|
38
48
|
export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia";
|
|
39
49
|
/**
|
package/dist/validation.js
CHANGED
|
@@ -70,6 +70,12 @@ const SNAPSHOT_REASONS = membersOf({
|
|
|
70
70
|
reconnected: true, "provider-requested": true,
|
|
71
71
|
});
|
|
72
72
|
const SESSION_CAPABILITIES = membersOf({ breaks: true, team: true });
|
|
73
|
+
const MEMBER_BREAKS = membersOf({
|
|
74
|
+
"awaiting-decision": true, granted: true, "starting-after-task": true,
|
|
75
|
+
});
|
|
76
|
+
const OFFERABLE_PHASES = membersOf({
|
|
77
|
+
pending: true, confirmed: true, preparing: true,
|
|
78
|
+
});
|
|
73
79
|
const TEAM_CAPABILITIES = membersOf({ breakControl: true, consultControl: true });
|
|
74
80
|
const COMPLETED_BY = membersOf({ agent: true, provider: true });
|
|
75
81
|
const EXPIRABLE_PHASES = membersOf({
|
|
@@ -283,7 +289,9 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
283
289
|
}
|
|
284
290
|
else {
|
|
285
291
|
methods.forEach((method, index) => {
|
|
286
|
-
into.oneOf(method, AUTHENTICATION_METHODS, "manifest.authenticationMethod", `${path}.authenticationMethods[${index}]`)
|
|
292
|
+
if (into.oneOf(method, AUTHENTICATION_METHODS, "manifest.authenticationMethod", `${path}.authenticationMethods[${index}]`) && methods.indexOf(method) !== index) {
|
|
293
|
+
into.add("manifest.authenticationMethod.unique", `${path}.authenticationMethods[${index}]`, `duplicate authentication method: ${String(method)}`);
|
|
294
|
+
}
|
|
287
295
|
});
|
|
288
296
|
}
|
|
289
297
|
if (channelValid)
|
|
@@ -331,19 +339,28 @@ function validateDestinationDirectory(value, path, into) {
|
|
|
331
339
|
return;
|
|
332
340
|
}
|
|
333
341
|
into.require(typeof value.allowManualEntry === "boolean", "task.destinations.allowManualEntry", `${path}.allowManualEntry`, "a directory must say whether manual entry is allowed");
|
|
342
|
+
// A directory with nothing in it and no typing is a control with nothing to offer.
|
|
343
|
+
if (value.allowManualEntry === false && !(Array.isArray(value.destinations) && value.destinations.length > 0)) {
|
|
344
|
+
into.add("task.destinations.offer", path, "a directory with no destinations must allow manual entry, or the control has nothing to offer");
|
|
345
|
+
}
|
|
334
346
|
if (value.destinations === undefined)
|
|
335
347
|
return;
|
|
336
348
|
if (!Array.isArray(value.destinations)) {
|
|
337
349
|
into.add("task.destinations.list", `${path}.destinations`, "destinations must be an array when present");
|
|
338
350
|
return;
|
|
339
351
|
}
|
|
352
|
+
const seen = new Set();
|
|
340
353
|
value.destinations.forEach((destination, index) => {
|
|
341
354
|
const at = `${path}.destinations[${index}]`;
|
|
342
355
|
if (!isPlainObject(destination)) {
|
|
343
356
|
into.add("task.destination.shape", at, "each destination must be an object");
|
|
344
357
|
return;
|
|
345
358
|
}
|
|
346
|
-
into.filled(destination.id, "task.destination.id", `${at}.id`, "a destination needs an id")
|
|
359
|
+
if (into.filled(destination.id, "task.destination.id", `${at}.id`, "a destination needs an id")) {
|
|
360
|
+
if (seen.has(destination.id))
|
|
361
|
+
into.add("task.destination.unique", `${at}.id`, `duplicate destination id: ${destination.id}`);
|
|
362
|
+
seen.add(destination.id);
|
|
363
|
+
}
|
|
347
364
|
into.filled(destination.label, "task.destination.label", `${at}.label`, "a destination needs a label");
|
|
348
365
|
into.filled(destination.address, "task.destination.address", `${at}.address`, "a destination needs an address");
|
|
349
366
|
into.oneOf(destination.kind, DESTINATION_KINDS, "task.destination.kind", `${at}.kind`);
|
|
@@ -359,6 +376,10 @@ function validateDispositions(value, path, into) {
|
|
|
359
376
|
if (value.required !== undefined) {
|
|
360
377
|
into.require(typeof value.required === "boolean", "task.dispositions.required", `${path}.required`, "required must be a boolean when present");
|
|
361
378
|
}
|
|
379
|
+
// A code must be collected before completion, so there must be one to collect.
|
|
380
|
+
if (value.required === true && !(Array.isArray(value.codes) && value.codes.length > 0)) {
|
|
381
|
+
into.add("task.dispositions.required.codes", `${path}.codes`, "a required disposition policy must publish at least one code");
|
|
382
|
+
}
|
|
362
383
|
if (value.notes !== undefined)
|
|
363
384
|
into.oneOf(value.notes, NOTES_POLICIES, "task.dispositions.notes", `${path}.notes`);
|
|
364
385
|
if (value.codes === undefined)
|
|
@@ -414,6 +435,7 @@ function validateBrowsers(value, path, into) {
|
|
|
414
435
|
return;
|
|
415
436
|
}
|
|
416
437
|
const seen = new Set();
|
|
438
|
+
const names = new Set();
|
|
417
439
|
value.forEach((browser, index) => {
|
|
418
440
|
const at = `${path}[${index}]`;
|
|
419
441
|
if (!isPlainObject(browser)) {
|
|
@@ -425,7 +447,13 @@ function validateBrowsers(value, path, into) {
|
|
|
425
447
|
into.add("task.browser.unique", `${at}.id`, `duplicate browser id: ${browser.id}`);
|
|
426
448
|
seen.add(browser.id);
|
|
427
449
|
}
|
|
428
|
-
|
|
450
|
+
// The name is an input to a `TAB_NAME` isolation scheme: two tabs with one name would
|
|
451
|
+
// silently share a session.
|
|
452
|
+
if (into.filled(browser.name, "task.browser.name", `${at}.name`, "a browser needs a name")) {
|
|
453
|
+
if (names.has(browser.name))
|
|
454
|
+
into.add("task.browser.name.unique", `${at}.name`, `duplicate browser name: ${browser.name}`);
|
|
455
|
+
names.add(browser.name);
|
|
456
|
+
}
|
|
429
457
|
into.filled(browser.purpose, "task.browser.purpose", `${at}.purpose`, "a browser needs a purpose");
|
|
430
458
|
if (into.filled(browser.url, "task.browser.url", `${at}.url`, "a browser needs a url")) {
|
|
431
459
|
let scheme;
|
|
@@ -463,13 +491,18 @@ function validateTaskAttributes(value, path, into) {
|
|
|
463
491
|
into.add("task.attributes.shape", path, "attributes must be an array when present");
|
|
464
492
|
return;
|
|
465
493
|
}
|
|
494
|
+
const keys = new Set();
|
|
466
495
|
value.forEach((attribute, index) => {
|
|
467
496
|
const at = `${path}[${index}]`;
|
|
468
497
|
if (!isPlainObject(attribute)) {
|
|
469
498
|
into.add("task.attribute.shape", at, "each task attribute must be an object");
|
|
470
499
|
return;
|
|
471
500
|
}
|
|
472
|
-
into.filled(attribute.key, "task.attribute.key", `${at}.key`, "a task attribute needs a key")
|
|
501
|
+
if (into.filled(attribute.key, "task.attribute.key", `${at}.key`, "a task attribute needs a key")) {
|
|
502
|
+
if (keys.has(attribute.key))
|
|
503
|
+
into.add("task.attribute.unique", `${at}.key`, `duplicate attribute key: ${attribute.key}`);
|
|
504
|
+
keys.add(attribute.key);
|
|
505
|
+
}
|
|
473
506
|
if (attribute.label !== undefined) {
|
|
474
507
|
into.filled(attribute.label, "task.attribute.label", `${at}.label`, "a label must not be empty when present");
|
|
475
508
|
}
|
|
@@ -509,6 +542,8 @@ function validateHandlingHistory(value, path, into) {
|
|
|
509
542
|
}
|
|
510
543
|
if (entry.by !== undefined) {
|
|
511
544
|
into.require(isUserId(entry.by), "task.handlingHistory.by", `${at}.by`, "by must be a non-empty user id; omit it when the person cannot be identified");
|
|
545
|
+
// On `queued` nobody takes part, so there is nothing to name.
|
|
546
|
+
into.require(entry.step !== "queued", "task.handlingHistory.by.unexpected", `${at}.by`, "a queued step names nobody");
|
|
512
547
|
}
|
|
513
548
|
});
|
|
514
549
|
}
|
|
@@ -611,6 +646,15 @@ function validateTaskInto(task, context, path, into) {
|
|
|
611
646
|
return;
|
|
612
647
|
}
|
|
613
648
|
const allowed = isChannel(context.channel) ? TASK_CAPABILITIES[context.channel] : TASK_CAPABILITIES.voice;
|
|
649
|
+
// A task that supplies browser definitions declares the capability that shows them, and one
|
|
650
|
+
// that declares it supplies at least one: the capability puts a panel in the workspace, and a
|
|
651
|
+
// panel with nothing in it is a control with nothing to offer.
|
|
652
|
+
if (Array.isArray(task.browsers) && task.browsers.length > 0 && capabilities.browsers !== true) {
|
|
653
|
+
into.add("task.browsers.capability", `${path}.browsers`, "a task that supplies browsers declares capabilities.browsers");
|
|
654
|
+
}
|
|
655
|
+
if (capabilities.browsers === true && Array.isArray(task.browsers) && task.browsers.length === 0) {
|
|
656
|
+
into.add("task.browsers.required", `${path}.browsers`, "a task that declares capabilities.browsers supplies at least one; with none, omit the capability");
|
|
657
|
+
}
|
|
614
658
|
for (const [name, declared] of Object.entries(capabilities)) {
|
|
615
659
|
if (declared === undefined)
|
|
616
660
|
continue;
|
|
@@ -669,6 +713,10 @@ function validateBreakState(value, path, into) {
|
|
|
669
713
|
into.filled(value[field], `break.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
|
|
670
714
|
}
|
|
671
715
|
}
|
|
716
|
+
// The refusal is the reason the control is withdrawn; beside `accepting: true` it explains nothing.
|
|
717
|
+
if (value.refusedReason !== undefined) {
|
|
718
|
+
into.require(value.accepting !== true, "break.refusedReason.accepting", `${path}.refusedReason`, "refusedReason is shown when accepting is false; omit it while the agent may ask");
|
|
719
|
+
}
|
|
672
720
|
if (value.retryAfterMs !== undefined) {
|
|
673
721
|
into.require(typeof value.retryAfterMs === "number" && Number.isFinite(value.retryAfterMs) && value.retryAfterMs >= 0, "break.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
|
|
674
722
|
}
|
|
@@ -678,14 +726,21 @@ function validateBreakState(value, path, into) {
|
|
|
678
726
|
// break that is not happening.
|
|
679
727
|
into.require(value.approval !== "not-requested", "break.activeReasonId.approval", `${path}.activeReasonId`, "activeReasonId must be omitted when no break is requested or in effect");
|
|
680
728
|
}
|
|
681
|
-
if (value.imposed !== undefined)
|
|
729
|
+
if (value.imposed !== undefined) {
|
|
682
730
|
validateImposedBreak(value.imposed, `${path}.imposed`, into);
|
|
731
|
+
// An imposed break is a break somebody placed; beside `not-requested` there is no break.
|
|
732
|
+
into.require(value.approval !== "not-requested", "break.imposed.approval", `${path}.imposed`, "an imposed break is a break in progress; not-requested says there is none");
|
|
733
|
+
}
|
|
683
734
|
if (value.reasons === undefined)
|
|
684
735
|
return;
|
|
685
736
|
if (!Array.isArray(value.reasons)) {
|
|
686
737
|
into.add("break.reasons.shape", `${path}.reasons`, "break reasons must be an array when present");
|
|
687
738
|
return;
|
|
688
739
|
}
|
|
740
|
+
// A provider that defines no codes omits the field: an empty list is a second spelling of that.
|
|
741
|
+
if (value.reasons.length === 0) {
|
|
742
|
+
into.add("break.reasons.empty", `${path}.reasons`, "a provider that defines no reasons omits the field rather than publishing an empty list");
|
|
743
|
+
}
|
|
689
744
|
const seen = new Set();
|
|
690
745
|
value.reasons.forEach((reason, index) => {
|
|
691
746
|
const at = `${path}.reasons[${index}]`;
|
|
@@ -705,6 +760,10 @@ function validateBreakState(value, path, into) {
|
|
|
705
760
|
into.require(reason.alwaysAvailable === true, "break.reason.alwaysAvailable", `${at}.alwaysAvailable`, "alwaysAvailable is declared by presence: send true or omit it");
|
|
706
761
|
}
|
|
707
762
|
});
|
|
763
|
+
// The active reason is one of the published ones, or it is a reason Omni cannot name.
|
|
764
|
+
if (typeof value.activeReasonId === "string" && value.activeReasonId.length > 0) {
|
|
765
|
+
into.require(seen.has(value.activeReasonId), "break.activeReasonId.known", `${path}.activeReasonId`, `activeReasonId names a reason the provider did not publish: ${value.activeReasonId}`);
|
|
766
|
+
}
|
|
708
767
|
}
|
|
709
768
|
export function validateTeamRoster(roster, path = "team", context = {}) {
|
|
710
769
|
const into = new Collector();
|
|
@@ -780,8 +839,13 @@ function validateTeamRosterInto(roster, path, context, into) {
|
|
|
780
839
|
into.oneOf(member.availability, TEAM_AVAILABILITIES, "team.member.availability", `${at}.availability`);
|
|
781
840
|
if (member.since !== undefined)
|
|
782
841
|
into.timestamp(member.since, "team.member.since", `${at}.since`);
|
|
783
|
-
if (member.break !== undefined)
|
|
784
|
-
|
|
842
|
+
if (member.break !== undefined) {
|
|
843
|
+
// Only an outstanding request appears here: `not-requested` is absence, `in-effect` is
|
|
844
|
+
// `availability: "on-break"`, and a denial never survives to be reported.
|
|
845
|
+
if (into.oneOf(member.break, MEMBER_BREAKS, "team.member.break", `${at}.break`)) {
|
|
846
|
+
into.require(member.availability !== "signed-out" && member.availability !== "on-break", "team.member.break.availability", `${at}.break`, "a member on a break or signed out has no request outstanding");
|
|
847
|
+
}
|
|
848
|
+
}
|
|
785
849
|
});
|
|
786
850
|
}
|
|
787
851
|
export function validateSnapshot(snapshot, manifest, path = "snapshot", context = {}) {
|
|
@@ -792,15 +856,25 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
792
856
|
}
|
|
793
857
|
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
794
858
|
into.oneOf(snapshot.status, CONNECTION_STATUSES, "snapshot.status", `${path}.status`);
|
|
795
|
-
into.filled(snapshot.sessionId, "snapshot.sessionId", `${path}.sessionId`, "a snapshot needs the session id it belongs to")
|
|
859
|
+
if (into.filled(snapshot.sessionId, "snapshot.sessionId", `${path}.sessionId`, "a snapshot needs the session id it belongs to")
|
|
860
|
+
&& context.sessionId !== undefined) {
|
|
861
|
+
into.require(snapshot.sessionId === context.sessionId, "snapshot.sessionId.mismatch", `${path}.sessionId`, `a snapshot for session ${String(snapshot.sessionId)} on a login whose session is ${context.sessionId}`);
|
|
862
|
+
}
|
|
796
863
|
validateBreakState(snapshot.break, `${path}.break`, into);
|
|
797
864
|
if (!Array.isArray(snapshot.tasks)) {
|
|
798
865
|
into.add("snapshot.tasks.shape", `${path}.tasks`, "a snapshot must carry a tasks array");
|
|
799
866
|
}
|
|
800
867
|
else {
|
|
801
868
|
const seen = new Set();
|
|
869
|
+
let assisting;
|
|
802
870
|
snapshot.tasks.forEach((task, index) => {
|
|
803
871
|
validateTaskInto(task, { channel }, `${path}.tasks[${index}]`, into);
|
|
872
|
+
// A lead assists one call at a time.
|
|
873
|
+
if (isPlainObject(task) && task.assisting !== undefined) {
|
|
874
|
+
if (assisting !== undefined)
|
|
875
|
+
into.add("snapshot.assisting.single", `${path}.tasks[${index}].assisting`, "a lead assists one call at a time");
|
|
876
|
+
assisting = index;
|
|
877
|
+
}
|
|
804
878
|
if (isPlainObject(task) && isTaskId(task.id)) {
|
|
805
879
|
if (seen.has(task.id))
|
|
806
880
|
into.add("task.id.unique", `${path}.tasks[${index}].id`, `duplicate task id: ${task.id}`);
|
|
@@ -919,7 +993,13 @@ function validateProviderSummary(value, path, into) {
|
|
|
919
993
|
into.add("event.summary.metrics.shape", `${path}.metrics`, "metrics must be an array when present");
|
|
920
994
|
return;
|
|
921
995
|
}
|
|
996
|
+
const ids = new Set();
|
|
922
997
|
value.metrics.forEach((metric, index) => {
|
|
998
|
+
if (isPlainObject(metric) && isFilled(metric.id)) {
|
|
999
|
+
if (ids.has(metric.id))
|
|
1000
|
+
into.add("event.summary.metric.unique", `${path}.metrics[${index}].id`, `duplicate metric id: ${metric.id}`);
|
|
1001
|
+
ids.add(metric.id);
|
|
1002
|
+
}
|
|
923
1003
|
const at = `${path}.metrics[${index}]`;
|
|
924
1004
|
if (!isPlainObject(metric)) {
|
|
925
1005
|
into.add("event.summary.metric.shape", at, "each metric must be an object");
|
|
@@ -938,7 +1018,11 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
938
1018
|
}
|
|
939
1019
|
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
940
1020
|
into.filled(envelope.id, "event.id", `${path}.id`, "an event needs an id");
|
|
941
|
-
into.filled(envelope.sessionId, "event.sessionId", `${path}.sessionId`, "an event needs the session id it belongs to")
|
|
1021
|
+
if (into.filled(envelope.sessionId, "event.sessionId", `${path}.sessionId`, "an event needs the session id it belongs to")
|
|
1022
|
+
&& context.sessionId !== undefined) {
|
|
1023
|
+
into.require(envelope.sessionId === context.sessionId, "event.sessionId.mismatch", `${path}.sessionId`, `an event for session ${String(envelope.sessionId)} on a login whose session is ${context.sessionId}`);
|
|
1024
|
+
}
|
|
1025
|
+
const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
|
|
942
1026
|
into.timestamp(envelope.occurredAt, "event.occurredAt", `${path}.occurredAt`);
|
|
943
1027
|
const event = envelope.event;
|
|
944
1028
|
if (!isPlainObject(event)) {
|
|
@@ -962,9 +1046,20 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
962
1046
|
break;
|
|
963
1047
|
case "task-offered":
|
|
964
1048
|
validateTaskInto(event.task, { channel }, `${at}.task`, into);
|
|
1049
|
+
// An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
|
|
1050
|
+
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1051
|
+
into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
|
|
1052
|
+
}
|
|
965
1053
|
if (event.acceptanceMode !== undefined) {
|
|
966
1054
|
into.oneOf(event.acceptanceMode, ACCEPTANCE_MODES, "event.taskOffered.acceptanceMode", `${at}.acceptanceMode`);
|
|
967
1055
|
}
|
|
1056
|
+
// The mode travels exactly when Omni said tasks may be auto-accepted.
|
|
1057
|
+
if (context.autoAcceptTasks === true) {
|
|
1058
|
+
into.require(event.acceptanceMode !== undefined, "event.taskOffered.acceptanceMode.required", `${at}.acceptanceMode`, "autoAcceptTasks is on, so task-offered carries an acceptanceMode");
|
|
1059
|
+
}
|
|
1060
|
+
else if (context.autoAcceptTasks === false) {
|
|
1061
|
+
into.require(event.acceptanceMode === undefined, "event.taskOffered.acceptanceMode.unexpected", `${at}.acceptanceMode`, "autoAcceptTasks is off, so every task requires agent acceptance and task-offered carries no acceptanceMode");
|
|
1062
|
+
}
|
|
968
1063
|
for (const field of ["allocationExpiresAt", "preparationEndsAt"]) {
|
|
969
1064
|
if (event[field] !== undefined)
|
|
970
1065
|
into.timestamp(event[field], `event.taskOffered.${field}`, `${at}.${field}`);
|
|
@@ -996,6 +1091,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
996
1091
|
validateTeamRosterInto(event.team, `${at}.team`, context, into);
|
|
997
1092
|
break;
|
|
998
1093
|
case "contacts-updated":
|
|
1094
|
+
into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
|
|
999
1095
|
if (!Array.isArray(event.contacts)) {
|
|
1000
1096
|
into.add("event.contacts.shape", `${at}.contacts`, "contacts must be an array");
|
|
1001
1097
|
}
|
|
@@ -1004,11 +1100,20 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1004
1100
|
}
|
|
1005
1101
|
break;
|
|
1006
1102
|
case "calendar-updated":
|
|
1103
|
+
into.require(idle.calendar === true, "event.calendar.capability", `${at}.scheduledActivities`, "calendar-updated requires the calendar idle capability");
|
|
1007
1104
|
if (!Array.isArray(event.scheduledActivities)) {
|
|
1008
1105
|
into.add("event.calendar.shape", `${at}.scheduledActivities`, "scheduledActivities must be an array");
|
|
1009
1106
|
}
|
|
1010
1107
|
else {
|
|
1011
|
-
|
|
1108
|
+
const ids = new Set();
|
|
1109
|
+
event.scheduledActivities.forEach((activity, index) => {
|
|
1110
|
+
validateScheduledActivityInto(activity, `${at}.scheduledActivities[${index}]`, into);
|
|
1111
|
+
if (isPlainObject(activity) && isFilled(activity.id)) {
|
|
1112
|
+
if (ids.has(activity.id))
|
|
1113
|
+
into.add("activity.id.unique", `${at}.scheduledActivities[${index}].id`, `duplicate activity id: ${activity.id}`);
|
|
1114
|
+
ids.add(activity.id);
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1012
1117
|
}
|
|
1013
1118
|
break;
|
|
1014
1119
|
default:
|
|
@@ -1039,6 +1144,35 @@ function validateFailureInto(value, path, into) {
|
|
|
1039
1144
|
into.require(typeof value.retryAfterMs === "number" && Number.isFinite(value.retryAfterMs) && value.retryAfterMs >= 0, "failure.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
|
|
1040
1145
|
}
|
|
1041
1146
|
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Validates the host's media state as Omni publishes it to an adapter. This is Omni's output, so
|
|
1149
|
+
* the check belongs to the host's own tests and to the harness, which validates whatever media a
|
|
1150
|
+
* test hands the adapter.
|
|
1151
|
+
*/
|
|
1152
|
+
export function validateHostMediaState(state, path = "media") {
|
|
1153
|
+
const into = new Collector();
|
|
1154
|
+
if (!isPlainObject(state)) {
|
|
1155
|
+
into.add("media.shape", path, "a host media state must be an object");
|
|
1156
|
+
return into.violations;
|
|
1157
|
+
}
|
|
1158
|
+
if (state.status === "ready") {
|
|
1159
|
+
into.require(typeof state.localAudio === "object" && state.localAudio !== null, "media.localAudio", `${path}.localAudio`, "ready carries the captured microphone");
|
|
1160
|
+
into.require(state.failure === undefined, "media.failure.unexpected", `${path}.failure`, "ready carries no failure");
|
|
1161
|
+
}
|
|
1162
|
+
else if (state.status === "unavailable") {
|
|
1163
|
+
if (state.failure === undefined) {
|
|
1164
|
+
into.add("media.failure.required", `${path}.failure`, "unavailable carries the failure that says why");
|
|
1165
|
+
}
|
|
1166
|
+
else {
|
|
1167
|
+
validateFailureInto(state.failure, `${path}.failure`, into);
|
|
1168
|
+
}
|
|
1169
|
+
into.require(state.localAudio === undefined, "media.localAudio.unexpected", `${path}.localAudio`, "unavailable carries no microphone");
|
|
1170
|
+
}
|
|
1171
|
+
else {
|
|
1172
|
+
into.add("media.status", `${path}.status`, `a host media state is ready or unavailable, not ${String(state.status)}`);
|
|
1173
|
+
}
|
|
1174
|
+
return into.violations;
|
|
1175
|
+
}
|
|
1042
1176
|
// Pinned to the result unions: each method's one success status, and the status that carries a
|
|
1043
1177
|
// failure. A method added to `Connection` without a row here is a compile error at the call site.
|
|
1044
1178
|
const RESULT_STATUSES = {
|
|
@@ -1155,6 +1289,9 @@ export function validateAuthenticationState(state, path = "authentication") {
|
|
|
1155
1289
|
else {
|
|
1156
1290
|
into.require(state.identity === undefined, "authentication.identity.unexpected", `${path}.identity`, `${state.status} must not carry an identity`);
|
|
1157
1291
|
}
|
|
1292
|
+
if (state.failure !== undefined) {
|
|
1293
|
+
into.require(state.status === "expired", "authentication.failure.unexpected", `${path}.failure`, "only an expired state may carry a failure");
|
|
1294
|
+
}
|
|
1158
1295
|
if (state.expiresAt !== undefined) {
|
|
1159
1296
|
into.require(state.status === "authenticated", "authentication.expiresAt.unexpected", `${path}.expiresAt`, "only an authenticated state may carry an expiry");
|
|
1160
1297
|
into.timestamp(state.expiresAt, "authentication.expiresAt", `${path}.expiresAt`);
|
package/guide.md
CHANGED
|
@@ -245,10 +245,20 @@ type AuthenticationFailure = {
|
|
|
245
245
|
field?: string;
|
|
246
246
|
};
|
|
247
247
|
|
|
248
|
+
type HostMediaState =
|
|
249
|
+
| { status: "ready"; localAudio: MediaStream }
|
|
250
|
+
| { status: "unavailable"; failure: ProtocolFailure };
|
|
251
|
+
|
|
252
|
+
type HostMedia = {
|
|
253
|
+
state(): HostMediaState;
|
|
254
|
+
subscribe(listener: (state: HostMediaState) => void): Unsubscribe;
|
|
255
|
+
};
|
|
256
|
+
|
|
248
257
|
type ConnectContext = {
|
|
249
258
|
protocolVersion: number;
|
|
250
259
|
sessionId: string;
|
|
251
260
|
autoAcceptTasks?: boolean;
|
|
261
|
+
media?: HostMedia;
|
|
252
262
|
signal?: AbortSignal;
|
|
253
263
|
log?: (entry: unknown) => void;
|
|
254
264
|
};
|
|
@@ -1491,6 +1501,7 @@ Creates one live provider connection for the signed-in agent.
|
|
|
1491
1501
|
| `protocolVersion` | Version negotiated before authentication. Fixed for this login. |
|
|
1492
1502
|
| `sessionId` | Omni-generated identity for this login. It is the same value passed as `AuthenticationContext.sessionId`, so an adapter can correlate this connection with the session that authenticated it. Stable across transport reconnects and changed only by a new login. |
|
|
1493
1503
|
| `autoAcceptTasks` | Agent provisioning policy relayed to the provider at login. Treated as `true` when omitted. When `true`, `task-offered` carries an `acceptanceMode`; when `false`, every task requires agent acceptance. |
|
|
1504
|
+
| `media` | The host's audio, present on a voice connection: `HostMedia`, observable for the life of the connection. See **Host media**. |
|
|
1494
1505
|
| `signal` | Optional cancellation signal. Stop startup promptly when aborted and do not begin new work. |
|
|
1495
1506
|
| `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
|
|
1496
1507
|
|
|
@@ -1731,7 +1742,7 @@ time. Runtime conformance checks also require the task channel to match its prov
|
|
|
1731
1742
|
| `channel` | Channel handling this task. It must equal the source provider's manifest channel. |
|
|
1732
1743
|
| `taskType` | Required provider-defined source or category of work, such as a voice `Queue Name`, `Mailbox Folder`, `Chat Source`, `Support`, `Billing`, or `Returns`. |
|
|
1733
1744
|
| `capabilities` | Controls and workspace features available for this specific task. |
|
|
1734
|
-
| `browsers` | Named browser definitions for the task workspace
|
|
1745
|
+
| `browsers` | Named browser definitions for the task workspace: at least one when the task declares the `browsers` capability, empty when it does not. |
|
|
1735
1746
|
| `contact` | Optional `Contact` for the person or entity on this task. Often a name and one address; a withheld caller ID may leave nothing to send at all. |
|
|
1736
1747
|
| `phase` | Current canonical task phase: `pending`, `confirmed`, `preparing`, `in-progress`, `paused`, or `completing`. |
|
|
1737
1748
|
| `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
|
|
@@ -1789,6 +1800,25 @@ allocation's `acceptanceMode`, moving the task from `pending` to `confirmed`. Th
|
|
|
1789
1800
|
subsequent transitions to `preparing` or `in-progress`; Omni does not infer them from the acceptance
|
|
1790
1801
|
command.
|
|
1791
1802
|
|
|
1803
|
+
**A task is never its audio.** A voice task is the allocation: the call is offered when it is
|
|
1804
|
+
routed to the agent and accepted as `acceptanceMode` dictates, and its presence and phase follow
|
|
1805
|
+
the provider's reports about the work — never the audio. Wherever audio moves — an offer, a hold, a
|
|
1806
|
+
consult, a conference leg joining or leaving, a transfer, a callback — the media follows
|
|
1807
|
+
separately, attaching through `openMedia` and ending with `task-media-ended`. Omni does not ring,
|
|
1808
|
+
bridge, or hold a line. How the phone rings, whether it rings at all, and where legs join and leave
|
|
1809
|
+
are the adapter's and the platform's, transient, and decide neither when a task exists nor what
|
|
1810
|
+
phase it is in.
|
|
1811
|
+
|
|
1812
|
+
The line runs between the provider's word and Omni's own senses. `task-media-ended` is the
|
|
1813
|
+
provider's report that primary handling ended — a fact about the work, which is why the completion
|
|
1814
|
+
allowance starts on it and the callback control appears on it — and Omni follows that report as it
|
|
1815
|
+
follows any other. What Omni never does is derive a task's state from its own media session: a
|
|
1816
|
+
stream that drops, a track that ends, a transport that disconnects, a microphone that fails, an
|
|
1817
|
+
endpoint re-registering change nothing about the task until the provider says so. Structurally:
|
|
1818
|
+
`task-media-ended` names a task whose work has begun, what follows it is `completing` or
|
|
1819
|
+
`task-ended`, and every task is introduced once — `exerciseAdapter` holds the stream to that from
|
|
1820
|
+
the connect snapshot on, and `assertMediaFollowsTheTask` holds any sequence.
|
|
1821
|
+
|
|
1792
1822
|
#### Completion timing
|
|
1793
1823
|
|
|
1794
1824
|
`completionMode` determines how completion is triggered. With `agent-command`, the provider keeps
|
|
@@ -2042,14 +2072,13 @@ browser that used it.
|
|
|
2042
2072
|
|
|
2043
2073
|
### Task capabilities
|
|
2044
2074
|
|
|
2045
|
-
Task capabilities belong to each `Task`. If `hold` is
|
|
2075
|
+
Task capabilities belong to each `Task`. If `hold` is omitted on one task, Omni
|
|
2046
2076
|
must not show or issue hold for that task even if another task from the same provider supports it.
|
|
2047
2077
|
|
|
2048
2078
|
```ts
|
|
2049
2079
|
const taskCapabilities = {
|
|
2050
2080
|
channel: "voice",
|
|
2051
2081
|
capabilities: {
|
|
2052
|
-
browsers: true,
|
|
2053
2082
|
hold: true,
|
|
2054
2083
|
dispositions: true,
|
|
2055
2084
|
},
|
|
@@ -2247,7 +2276,7 @@ nothing while none are being accepted — so they are not published separately.
|
|
|
2247
2276
|
| `refusedReason` | Display-ready reason shown when `accepting` is false — a standing gate that applies to everyone. |
|
|
2248
2277
|
| `decisionReason` | The words whoever decided attached, from `decide.reason`. About one request and one decision, not a standing gate. |
|
|
2249
2278
|
| `retryAfterMs` | How long until the agent may retry, when the provider can say. |
|
|
2250
|
-
| `reasons` | Not-ready codes this provider offers. Omitted when it defines none. |
|
|
2279
|
+
| `reasons` | Not-ready codes this provider offers. Omitted when it defines none; an empty list is refused, being a second spelling of the same fact. |
|
|
2251
2280
|
| `activeReasonId` | The `BreakReason.id` the current break is on. Omitted when there is no break, or when the provider cannot say. |
|
|
2252
2281
|
| `imposed` | Set when the break was placed on the agent rather than requested. |
|
|
2253
2282
|
|
|
@@ -2797,26 +2826,50 @@ Omni, and Omni registers the endpoint for it.
|
|
|
2797
2826
|
|
|
2798
2827
|
That removes a whole class of state the provider would otherwise own and Omni would have to track,
|
|
2799
2828
|
and it removes the branch that came with it: no command has to ask where the audio went before
|
|
2800
|
-
deciding who performs it.
|
|
2829
|
+
deciding who performs it. Nor does the audio ever stand in for the task: a task's presence and
|
|
2830
|
+
phase follow the provider's reports about the work, and the media — attaching, moving through a
|
|
2831
|
+
hold, a consult, a conference or a transfer, and ending — is transient beside it. See **A task is
|
|
2832
|
+
never its audio** under **Task allocation lifecycle**.
|
|
2833
|
+
|
|
2834
|
+
### Host media
|
|
2835
|
+
|
|
2836
|
+
Omni facilitates the microphone and does not take responsibility for its failure. It captures the
|
|
2837
|
+
agent's microphone once as the voice connection opens, so the permission prompt lands while the
|
|
2838
|
+
agent is signing in rather than over a contact; it prompts, retries on the agent's request, and
|
|
2839
|
+
tells the agent what failed. What it does not do is decide for the adapter what a missing
|
|
2840
|
+
microphone means. `ConnectContext.media` carries the host's audio as a `HostMedia`, observable for
|
|
2841
|
+
the life of the connection the way authentication is:
|
|
2842
|
+
|
|
2843
|
+
| State | Contract |
|
|
2844
|
+
| --- | --- |
|
|
2845
|
+
| `ready` | Omni has the microphone; `localAudio` is it, and the same stream `openMedia` receives. |
|
|
2846
|
+
| `unavailable` | Omni does not: permission refused, no device, capture lost. `failure` says which, in words Omni has already shown the agent. |
|
|
2847
|
+
|
|
2848
|
+
Omni republishes the state whenever it changes — a permission granted late, a device unplugged —
|
|
2849
|
+
and the adapter does what its platform needs. A platform that bridges audio without a host-side
|
|
2850
|
+
input carries on; one that needs it may put the agent not-ready with the platform, refuse calls,
|
|
2851
|
+
or answer `openMedia` `unavailable` with a failure Omni shows. The choice is the adapter's because
|
|
2852
|
+
only the adapter knows where its platform's audio lands.
|
|
2801
2853
|
|
|
2802
2854
|
### Capacity around setup
|
|
2803
2855
|
|
|
2804
2856
|
Connecting is not the same as being able to take a call. A provider that treats a live connection
|
|
2805
|
-
as reachability opens a window where it believes the agent is available and
|
|
2806
|
-
|
|
2857
|
+
as reachability opens a window where it believes the agent is available and the agent is not yet
|
|
2858
|
+
set up — the adapter's own registration incomplete, its credentials not yet renewed.
|
|
2807
2859
|
|
|
2808
|
-
Nothing closes that window, because nothing opens it: **Omni states no capacity until the
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2860
|
+
Nothing closes that window, because nothing opens it: **Omni states no capacity until the
|
|
2861
|
+
connection is established**, and **Work is pulled, never pushed** makes an allocation with none
|
|
2862
|
+
stated a violation. Capacity does not wait on the microphone: whether an agent without host audio
|
|
2863
|
+
can take calls is the platform's question, answered by the adapter from **Host media**, not by
|
|
2864
|
+
Omni withholding capacity for every platform alike.
|
|
2812
2865
|
|
|
2813
|
-
Capacity follows **automatically** once
|
|
2814
|
-
become available.
|
|
2866
|
+
Capacity follows **automatically** once the connection is established; the agent does not press
|
|
2867
|
+
anything to become available.
|
|
2815
2868
|
|
|
2816
2869
|
| Situation | What Omni sends |
|
|
2817
2870
|
| --- | --- |
|
|
2818
|
-
|
|
|
2819
|
-
|
|
|
2871
|
+
| Connecting | Nothing. No capacity has been stated, so nothing may be allocated. |
|
|
2872
|
+
| Connected and idle | `setCapacity({ count: n })`, and the host media state alongside it. |
|
|
2820
2873
|
| A task starts or ends | Nothing. The provider counts its own against the ceiling. |
|
|
2821
2874
|
| The agent's provisioned capacity changes | `setCapacity({ count: n })` |
|
|
2822
2875
|
| Agent asks for a break | `requestBreak`. Capacity is unchanged and work continues. |
|
|
@@ -2842,7 +2895,8 @@ The adapter speaks whatever its platform speaks — SIP over WebSocket, a vendor
|
|
|
2842
2895
|
WebRTC — and **none of that appears in this contract**. Registration, signalling, credential
|
|
2843
2896
|
renewal and reconnect are the adapter's, exactly as its authentication and transport already
|
|
2844
2897
|
are. Omni owns what belongs to the host: the microphone, the output element, mute, and when a
|
|
2845
|
-
session ends
|
|
2898
|
+
session ends — owning the microphone meaning capturing it, prompting, retrying and saying how it
|
|
2899
|
+
stands, never deciding for the adapter what a missing one means (see **Host media**).
|
|
2846
2900
|
|
|
2847
2901
|
| Member | Contract |
|
|
2848
2902
|
| --- | --- |
|
|
@@ -2850,9 +2904,10 @@ session ends.
|
|
|
2850
2904
|
| `setMuted(muted)` | Mutes the agent's microphone on this session. |
|
|
2851
2905
|
| `close()` | Releases the session. Omni calls it when the task ends. |
|
|
2852
2906
|
|
|
2853
|
-
`localAudio` is the agent's microphone
|
|
2854
|
-
|
|
2855
|
-
|
|
2907
|
+
`localAudio` is the agent's microphone as Omni captured it, the same stream **Host media** reports,
|
|
2908
|
+
and absent while that state is `unavailable`. A provider that bridges audio without a host-side
|
|
2909
|
+
input may ignore it; one that needs it and finds it absent answers `unavailable` with a failure
|
|
2910
|
+
Omni shows the agent.
|
|
2856
2911
|
|
|
2857
2912
|
**A task-scoped session does not oblige one call per task.** A platform holding a nailed-up
|
|
2858
2913
|
leg for a whole shift may return the same session for every task and release the underlying
|
|
@@ -3182,6 +3237,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
3182
3237
|
| `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
|
|
3183
3238
|
| `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
|
|
3184
3239
|
| `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
|
|
3240
|
+
| `validateHostMediaState(state)` | The host's own media state as published to an adapter: `ready` with the microphone, or `unavailable` with the failure that says why. The harness validates whatever media a test hands the adapter. |
|
|
3185
3241
|
| `validateResult(result, method)` | What a connection method answered: the status it gives, a failure where the status says so and nowhere else, the failure's shape, and that an `omni.` code is one this contract names. |
|
|
3186
3242
|
| `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. |
|
|
3187
3243
|
|
|
@@ -3268,6 +3324,7 @@ cannot be established from TypeScript structure alone.
|
|
|
3268
3324
|
| `assertReached(result, subjects)` | The exercise met every subject named; throws listing those it did not. Pair it with a clean `exerciseAdapter` result. |
|
|
3269
3325
|
| `assertAuthenticationRestoreAndExpiry(states)` | A restored authenticated session can refresh and ends in expiry. Every state is validated. |
|
|
3270
3326
|
| `assertReconnectWithMissedAssignments(before, reconnect, ids)` | A reconnect snapshot restores assignments received while offline. |
|
|
3327
|
+
| `assertMediaFollowsTheTask(envelopes, snapshot?)` | The media follows the task and never decides it: every task is introduced once, `task-media-ended` names a task whose work has begun, and what follows it is `completing` or `task-ended`. The harness applies the same rules to every event after the connect snapshot (`stream.*`). |
|
|
3271
3328
|
| `assertBreakParticipants(candidates, participants)` | A break attempt asks every usable provider holding capacity, `refreshing` included, and nothing of a provider whose login is `expired`. |
|
|
3272
3329
|
| `assertBreakBeginsAfterTask(steps)` | A break asked for on a task is committed as `starting-after-task` while work remains and reaches `in-effect` only once nothing is outstanding — never beside a task, never later than the step that has none. |
|
|
3273
3330
|
| `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
|