@xema/omni-protocol 0.1.58 → 0.1.60
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 +54 -18
- package/dist/index.js +3 -1
- package/dist/testing.d.ts +6 -3
- package/dist/testing.js +86 -23
- package/dist/validation.d.ts +6 -0
- package/dist/validation.js +68 -12
- package/guide.md +147 -52
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -335,13 +335,34 @@ export interface AuthenticationSession {
|
|
|
335
335
|
* does not decide for the adapter what a missing one means. The adapter does what its platform
|
|
336
336
|
* needs: go not-ready, refuse calls, or carry on because audio lands elsewhere.
|
|
337
337
|
*/
|
|
338
|
+
/**
|
|
339
|
+
* Who silenced a device that is present and permitted: the host, by its own Mute, or the station --
|
|
340
|
+
* a slider on the headset, the operating system -- which the host observes and, on a browser,
|
|
341
|
+
* cannot touch. The same word on a `muted` handling leg says whose the silence was.
|
|
342
|
+
*/
|
|
343
|
+
export type MutedBy = "host" | "station";
|
|
344
|
+
export declare const MUTED_BY: readonly ["host", "station"];
|
|
345
|
+
/**
|
|
346
|
+
* What pressing Mute does on this host. `stream`: the host stops the audio it sends, and the
|
|
347
|
+
* microphone keeps capturing. `station`: the host mutes the microphone itself at the operating
|
|
348
|
+
* system, which every application on the machine then shares. A browser can only do the first;
|
|
349
|
+
* a native host says which it does. Stated by the host on a softphone login, never inferred
|
|
350
|
+
* from what kind of application it is.
|
|
351
|
+
*/
|
|
352
|
+
export type HostMute = "stream" | "station";
|
|
353
|
+
export declare const HOST_MUTES: readonly ["stream", "station"];
|
|
338
354
|
export type HostAudioInput =
|
|
339
|
-
/** Omni has the microphone. `flowing` is false while
|
|
340
|
-
{
|
|
355
|
+
/** Omni has the microphone. `flowing` is false while no audio moves through it, and then `mutedBy` says who stopped it. */
|
|
356
|
+
({
|
|
341
357
|
status: "available";
|
|
342
358
|
localAudio: MediaStream;
|
|
343
|
-
|
|
344
|
-
|
|
359
|
+
} & ({
|
|
360
|
+
flowing: true;
|
|
361
|
+
mutedBy?: never;
|
|
362
|
+
} | {
|
|
363
|
+
flowing: false;
|
|
364
|
+
mutedBy: MutedBy;
|
|
365
|
+
}))
|
|
345
366
|
/** Omni does not, and `reason` says which fix the agent needs; `failure` is the words Omni showed them. */
|
|
346
367
|
| {
|
|
347
368
|
status: "unavailable";
|
|
@@ -356,9 +377,17 @@ export type HostAudioInput =
|
|
|
356
377
|
export type HostAudioUnavailableReason = "no-device" | "denied" | "not-asked" | "in-use" | "lost";
|
|
357
378
|
/** Why the host has no speaker: no device, or one that was removed. */
|
|
358
379
|
export type HostOutputUnavailableReason = "no-device" | "lost";
|
|
359
|
-
export type HostAudioOutput =
|
|
380
|
+
export type HostAudioOutput =
|
|
381
|
+
/** Omni has a speaker. `flowing` is stated only where the host can know it: false while the output is silenced, and then `mutedBy` says who did it. */
|
|
382
|
+
({
|
|
360
383
|
status: "available";
|
|
384
|
+
} & ({
|
|
385
|
+
flowing?: true;
|
|
386
|
+
mutedBy?: never;
|
|
361
387
|
} | {
|
|
388
|
+
flowing: false;
|
|
389
|
+
mutedBy: MutedBy;
|
|
390
|
+
})) | {
|
|
362
391
|
status: "unavailable";
|
|
363
392
|
reason: HostOutputUnavailableReason;
|
|
364
393
|
failure: ProtocolFailure;
|
|
@@ -393,6 +422,8 @@ export interface HostGuarantees {
|
|
|
393
422
|
}
|
|
394
423
|
export interface Host {
|
|
395
424
|
guarantees: HostGuarantees;
|
|
425
|
+
/** What pressing Mute does on this host. Stated on a softphone login, where the host holds the microphone; absent on a desk phone and off voice. */
|
|
426
|
+
mute?: HostMute;
|
|
396
427
|
report(): HostReport;
|
|
397
428
|
subscribe(listener: (report: HostReport) => void): Unsubscribe;
|
|
398
429
|
}
|
|
@@ -492,7 +523,6 @@ export interface SharedTaskCapabilities {
|
|
|
492
523
|
*/
|
|
493
524
|
export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ? SharedTaskCapabilities & {
|
|
494
525
|
decline?: Lockable<true>;
|
|
495
|
-
mute?: Lockable<true>;
|
|
496
526
|
hold?: Lockable<true>;
|
|
497
527
|
/** The agent may end the whole call: everyone leaves and the media ends, the task stays for its wrap-up. */
|
|
498
528
|
endCall?: Lockable<true>;
|
|
@@ -623,6 +653,8 @@ export interface TaskHandlingStep {
|
|
|
623
653
|
* provider could not attribute it, which is a different claim and a legitimate one.
|
|
624
654
|
*/
|
|
625
655
|
by?: UserId;
|
|
656
|
+
/** On a `muted` step, and only there: whose the silence was, as the host reported it. */
|
|
657
|
+
mutedBy?: MutedBy;
|
|
626
658
|
}
|
|
627
659
|
/**
|
|
628
660
|
* How the task ends, and how long after handling the provider allows for it.
|
|
@@ -830,7 +862,7 @@ export type TaskOutcome = {
|
|
|
830
862
|
failure: ProtocolFailure;
|
|
831
863
|
};
|
|
832
864
|
export declare const TASK_COMMAND_NAMES: {
|
|
833
|
-
readonly voice: readonly ["answer", "decline", "call", "
|
|
865
|
+
readonly voice: readonly ["answer", "decline", "call", "hold", "resume", "end-call", "connect-back", "transfer", "lead-assist", "conference", "recording", "complete"];
|
|
834
866
|
readonly chat: readonly ["accept", "decline", "pause", "resume", "complete"];
|
|
835
867
|
readonly email: readonly ["accept", "decline", "complete"];
|
|
836
868
|
};
|
|
@@ -848,9 +880,6 @@ export type VoiceTaskCommand = {
|
|
|
848
880
|
| {
|
|
849
881
|
type: "call";
|
|
850
882
|
dialId: DialId;
|
|
851
|
-
} | {
|
|
852
|
-
type: "mute";
|
|
853
|
-
muted: boolean;
|
|
854
883
|
} | {
|
|
855
884
|
type: "hold";
|
|
856
885
|
} | {
|
|
@@ -1128,7 +1157,7 @@ export interface TeamRoster {
|
|
|
1128
1157
|
}
|
|
1129
1158
|
/** A capability a team policy can name: any task control, new call, or a skill by its provider id. */
|
|
1130
1159
|
export type PolicyKey = Exclude<keyof TaskCapabilities<"voice">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"chat">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"email">, keyof SharedTaskCapabilities> | "dial" | `skill:${string}`;
|
|
1131
|
-
/** On for everyone, off for everyone, or the agent's own choice. Only `hold
|
|
1160
|
+
/** On for everyone, off for everyone, or the agent's own choice. Only `hold` and skills may be `person`. */
|
|
1132
1161
|
export type TeamPolicySetting = "on" | "off" | "person";
|
|
1133
1162
|
/** One policy as the lead sees it: the setting, who set it, and `lockedBy` when a level above the team made it theirs to keep. */
|
|
1134
1163
|
export interface TeamPolicy extends Resolved {
|
|
@@ -1222,12 +1251,12 @@ export type OpenMediaResult = {
|
|
|
1222
1251
|
};
|
|
1223
1252
|
/** The provider's complete state at one moment. It replaces what Omni holds; never a patch. */
|
|
1224
1253
|
/**
|
|
1225
|
-
* What the team may leave to the person: a capability by its own name -- `hold
|
|
1254
|
+
* What the team may leave to the person: a capability by its own name -- `hold` -- or a
|
|
1226
1255
|
* skill by its provider id. The same key as in `Task.capabilities`, because it is the same
|
|
1227
1256
|
* capability seen at another level. Connecting back and a new call are never the person's; they
|
|
1228
1257
|
* are the team's, on or off, within what the queue allows.
|
|
1229
1258
|
*/
|
|
1230
|
-
export type PreferenceId = "hold" |
|
|
1259
|
+
export type PreferenceId = "hold" | `skill:${string}`;
|
|
1231
1260
|
/**
|
|
1232
1261
|
* Who stated a value as it stands: a level -- `person` among them -- or `provisioning`, the
|
|
1233
1262
|
* protocol's own word for "no level has said anything and the provider's default applies".
|
|
@@ -1260,8 +1289,8 @@ export type SetPreferenceRequest = {
|
|
|
1260
1289
|
inherit: true;
|
|
1261
1290
|
};
|
|
1262
1291
|
/**
|
|
1263
|
-
* The host's report of a handling leg it performed itself -- a mute, which
|
|
1264
|
-
* the provider -- so the provider's record has an account of it. Keyed by `step` and `at`: the
|
|
1292
|
+
* The host's report of a handling leg it performed itself -- a mute, which is the host's and
|
|
1293
|
+
* never the provider's -- so the provider's record has an account of it. Keyed by `step` and `at`: the
|
|
1265
1294
|
* same entry is reported when it begins, as often as the host cares to while it runs, and once
|
|
1266
1295
|
* more with `ended`, when `seconds` is the final duration. The host is the authority for the
|
|
1267
1296
|
* legs it performs, so `seconds` may say how long so far at any time; the end is stated, never
|
|
@@ -1269,9 +1298,16 @@ export type SetPreferenceRequest = {
|
|
|
1269
1298
|
*/
|
|
1270
1299
|
export type HandlingReport = {
|
|
1271
1300
|
taskId: TaskId;
|
|
1272
|
-
step: HandlingStep;
|
|
1273
1301
|
at: IsoTimestamp;
|
|
1274
|
-
} & (
|
|
1302
|
+
} & (
|
|
1303
|
+
/** A muted leg says whose the silence was: the host's own Mute, or the station's slider or system. */
|
|
1304
|
+
{
|
|
1305
|
+
step: "muted";
|
|
1306
|
+
mutedBy: MutedBy;
|
|
1307
|
+
} | {
|
|
1308
|
+
step: Exclude<HandlingStep, "muted">;
|
|
1309
|
+
mutedBy?: never;
|
|
1310
|
+
}) & ({
|
|
1275
1311
|
ended: true;
|
|
1276
1312
|
seconds: DurationSeconds;
|
|
1277
1313
|
} | {
|
|
@@ -1443,7 +1479,7 @@ export interface Connection<C extends Channel = Channel> {
|
|
|
1443
1479
|
openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
|
|
1444
1480
|
/** Required when the login declares `capabilities.preferences`: the person's own choice, kept by the provider and republished as `authenticated`. */
|
|
1445
1481
|
setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
|
|
1446
|
-
/** Records a handling leg the host performed. Required of a connection
|
|
1482
|
+
/** Records a handling leg the host performed. Required of a softphone login's connection: the host mutes its microphone on any call, and the record is the provider's. */
|
|
1447
1483
|
recordStep?(report: HandlingReport): Promise<HandlingReportResult>;
|
|
1448
1484
|
}
|
|
1449
1485
|
export interface Adapter<C extends Channel = Channel> {
|
package/dist/index.js
CHANGED
|
@@ -27,6 +27,8 @@ export const IDLE_CAPABILITY_UI = {
|
|
|
27
27
|
contacts: "Contacts",
|
|
28
28
|
};
|
|
29
29
|
export const MONITOR_MODES = ["monitor", "whisper", "barge"];
|
|
30
|
+
export const MUTED_BY = ["host", "station"];
|
|
31
|
+
export const HOST_MUTES = ["stream", "station"];
|
|
30
32
|
// ---------------------------------------------------------------------------
|
|
31
33
|
// Task workspace.
|
|
32
34
|
// ---------------------------------------------------------------------------
|
|
@@ -73,7 +75,7 @@ export const CAPABILITY_SOURCES = ["queue", "ungoverned", "undetermined"];
|
|
|
73
75
|
// Task commands.
|
|
74
76
|
// ---------------------------------------------------------------------------
|
|
75
77
|
export const TASK_COMMAND_NAMES = {
|
|
76
|
-
voice: ["answer", "decline", "call", "
|
|
78
|
+
voice: ["answer", "decline", "call", "hold", "resume", "end-call",
|
|
77
79
|
"connect-back", "transfer", "lead-assist", "conference", "recording", "complete"],
|
|
78
80
|
chat: ["accept", "decline", "pause", "resume", "complete"],
|
|
79
81
|
email: ["accept", "decline", "complete"],
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type Task, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type Host, type HostGuarantees, type HostReport, type Manifest, type ProviderEvent } from "./index.js";
|
|
1
|
+
import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type Task, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type Host, type HostGuarantees, type HostMute, type HostReport, type Manifest, type ProviderEvent } from "./index.js";
|
|
2
2
|
import { type ProtocolViolation } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation } from "./validation.js";
|
|
4
4
|
/**
|
|
@@ -153,8 +153,11 @@ export declare function assertBreakFollowsItsRequests(envelopes: readonly Provid
|
|
|
153
153
|
* follows the media ending is `completing` or `task-ended`.
|
|
154
154
|
*/
|
|
155
155
|
export declare function assertMediaFollowsTheTask(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
|
|
156
|
-
/**
|
|
157
|
-
|
|
156
|
+
/**
|
|
157
|
+
* A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`.
|
|
158
|
+
* A softphone host states what its Mute does; a desk-phone or conversation host has no microphone and states nothing.
|
|
159
|
+
*/
|
|
160
|
+
export declare function stillHost(report?: HostReport, guarantees?: HostGuarantees, mute?: HostMute): Host;
|
|
158
161
|
/** One provider as the host sees it when freezing the providers a break attempt asks. */
|
|
159
162
|
export interface BreakCandidate {
|
|
160
163
|
id: string;
|
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, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateHostMute, 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
|
|
@@ -271,6 +271,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
271
271
|
violations.push({ rule: "context.host.audio.unexpected", path: "context.host.audio",
|
|
272
272
|
message: adapter.manifest.channel === "voice" ? "a desk-phone login has no audio in the host to report" : `a ${adapter.manifest.channel} connection has no audio for the host to report` });
|
|
273
273
|
}
|
|
274
|
+
// What the host's Mute does is stated where the host holds a microphone, and nowhere else.
|
|
275
|
+
violations.push(...validateHostMute(context.host.mute, softphone, "context.host.mute"));
|
|
274
276
|
unsubscribeHost = context.host.subscribe(report => {
|
|
275
277
|
violations.push(...validateHostReport(report, "context.host"));
|
|
276
278
|
});
|
|
@@ -289,6 +291,10 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
289
291
|
// On a softphone the call's audio lands in Omni, so the adapter has to open it; on a desk phone the host opens nothing.
|
|
290
292
|
if (softphone)
|
|
291
293
|
requireMethod(live, "openMedia", "the login is on a softphone");
|
|
294
|
+
// The microphone is the host's, so on a softphone every call can be muted by it, and the
|
|
295
|
+
// record of that leg is the provider's to take; on a desk phone the host holds no microphone.
|
|
296
|
+
if (softphone)
|
|
297
|
+
requireMethod(live, "recordStep", "the login is on a softphone, whose microphone the host mutes");
|
|
292
298
|
const eventIds = new Set();
|
|
293
299
|
// The drive waits on events: each waiter is offered every envelope as it lands.
|
|
294
300
|
const waiters = new Set();
|
|
@@ -304,8 +310,6 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
304
310
|
violations.push(...undeterminedTasks(eventTasks(envelope), "event"));
|
|
305
311
|
if (eventNamesUsers(envelope))
|
|
306
312
|
requireMethod(live, "describeUsers", "an event publishes a UserId");
|
|
307
|
-
if (eventDeclaresMute(envelope))
|
|
308
|
-
requireMethod(live, "recordStep", "a task declares mute, which the host performs and must have somewhere to record");
|
|
309
313
|
// Cross-event rules apply once the stream has a beginning: the connect snapshot.
|
|
310
314
|
if (seeded)
|
|
311
315
|
violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
|
|
@@ -328,9 +332,6 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
328
332
|
requireCapabilityMethods(live, current().capabilities);
|
|
329
333
|
if (publishesUserIds(snapshot))
|
|
330
334
|
requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
|
|
331
|
-
if (isRecord(snapshot) && Array.isArray(snapshot.tasks) && snapshot.tasks.some(taskDeclaresMute)) {
|
|
332
|
-
requireMethod(live, "recordStep", "a task declares mute, which the host performs and must have somewhere to record");
|
|
333
|
-
}
|
|
334
335
|
// Capacity is stated, not requested: nothing may be allocated until it is, so a connection
|
|
335
336
|
// that will not accept one is a connection nothing can be given to.
|
|
336
337
|
// The guide's obligation on a voice adapter: consult the host before declaring the agent
|
|
@@ -369,7 +370,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
369
370
|
const localAudio = isRecord(first) && isRecord(first.audio) && isRecord(first.audio.input) && first.audio.input.status === "available"
|
|
370
371
|
? first.audio.input.localAudio : undefined;
|
|
371
372
|
violations.push(...await driveOneCall({
|
|
372
|
-
connection: live, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
|
|
373
|
+
connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
|
|
373
374
|
timeoutMs: options.driveTimeoutMs ?? 5000,
|
|
374
375
|
}));
|
|
375
376
|
}
|
|
@@ -445,18 +446,6 @@ const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.handlingHisto
|
|
|
445
446
|
(isRecord(task.leadAssist) && task.leadAssist.leadId !== undefined) ||
|
|
446
447
|
isRecord(task.assisting) ||
|
|
447
448
|
isRecord(task.monitoring));
|
|
448
|
-
/** Mute is the leg the host performs, so a task that allows it obliges the provider to take the record. */
|
|
449
|
-
const taskDeclaresMute = (task) => isRecord(task) && isRecord(task.capabilities) && task.capabilities.mute !== undefined;
|
|
450
|
-
function eventDeclaresMute(envelope) {
|
|
451
|
-
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
452
|
-
if (!isRecord(event))
|
|
453
|
-
return false;
|
|
454
|
-
if (event.type === "task-offered" || event.type === "task-updated")
|
|
455
|
-
return taskDeclaresMute(event.task);
|
|
456
|
-
if (event.type === "snapshot" && isRecord(event.snapshot) && Array.isArray(event.snapshot.tasks))
|
|
457
|
-
return event.snapshot.tasks.some(taskDeclaresMute);
|
|
458
|
-
return false;
|
|
459
|
-
}
|
|
460
449
|
/** Whether an event publishes a `UserId`, on a roster, a task, or the snapshot a reconnect carries. */
|
|
461
450
|
function eventNamesUsers(envelope) {
|
|
462
451
|
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
@@ -1034,6 +1023,49 @@ async function driveOneCall(drive) {
|
|
|
1034
1023
|
else if (isRecord(opened))
|
|
1035
1024
|
refuse("drive.openMedia.unavailable", "drive.openMedia", "a softphone login's adapter could not open the call's audio");
|
|
1036
1025
|
}
|
|
1026
|
+
// 3b. The microphone is the host's. With the audio open, the drive mutes it for a moment and
|
|
1027
|
+
// reports the leg the provider's record would otherwise miss -- begun, then ended -- and
|
|
1028
|
+
// expects each report recorded. If the provider restates the task's record afterwards, the leg is in it.
|
|
1029
|
+
let mutedLeg;
|
|
1030
|
+
if (session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function") {
|
|
1031
|
+
const at = new Date().toISOString();
|
|
1032
|
+
const report = async (body) => {
|
|
1033
|
+
// The drive holds its own report to the contract before it crosses, as a host must.
|
|
1034
|
+
const leg = { taskId, step: "muted", at, mutedBy: "host", ...body };
|
|
1035
|
+
const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
|
|
1036
|
+
found.push(...own);
|
|
1037
|
+
if (own.length > 0)
|
|
1038
|
+
return;
|
|
1039
|
+
let answer;
|
|
1040
|
+
try {
|
|
1041
|
+
answer = await drive.connection.recordStep(leg);
|
|
1042
|
+
}
|
|
1043
|
+
catch (error) {
|
|
1044
|
+
refuse("drive.recordStep.rejected", "drive.recordStep", `recordStep rejected rather than answered: ${String(error)}`);
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
found.push(...validateResult(answer, "recordStep", "drive.recordStep.result"));
|
|
1048
|
+
if (isRecord(answer) && answer.status === "failed") {
|
|
1049
|
+
refuse("drive.recordStep.failed", "drive.recordStep", `the provider refused to record the host's muted leg: ${String(isRecord(answer.failure) ? answer.failure.code : answer.failure)}`);
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
const setMuted = (muted) => {
|
|
1053
|
+
try {
|
|
1054
|
+
session.setMuted(muted);
|
|
1055
|
+
}
|
|
1056
|
+
catch {
|
|
1057
|
+
refuse("drive.openMedia.setMuted", "drive.openMedia", `the media session threw on setMuted(${String(muted)})`);
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
const began = Date.now();
|
|
1061
|
+
setMuted(true);
|
|
1062
|
+
await report({});
|
|
1063
|
+
// A leg's duration is whole seconds and a leg shorter than one cannot be stated, so the mute holds for one.
|
|
1064
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1065
|
+
setMuted(false);
|
|
1066
|
+
await report({ seconds: Math.round((Date.now() - began) / 1000), ended: true });
|
|
1067
|
+
mutedLeg = { at, task: latestTask() };
|
|
1068
|
+
}
|
|
1037
1069
|
// 4. Hold and resume, where offered.
|
|
1038
1070
|
if (latestTask().phase === "in-progress" && offers("hold")) {
|
|
1039
1071
|
if (await send({ type: drive.channel === "chat" ? "pause" : "hold" }) !== undefined) {
|
|
@@ -1053,7 +1085,21 @@ async function driveOneCall(drive) {
|
|
|
1053
1085
|
}, cursor);
|
|
1054
1086
|
if (mediaEnded !== undefined)
|
|
1055
1087
|
cursor = mediaEnded.at;
|
|
1056
|
-
await updated(t => t.phase === "completing", "the task completing after its media ended")
|
|
1088
|
+
if (await updated(t => t.phase === "completing", "the task completing after its media ended") !== undefined && offers("hold")) {
|
|
1089
|
+
// The other direction of step 4: the same control, still declared, on a call that is over.
|
|
1090
|
+
// A host holds it back (command.phase.handling), and an adapter that receives it anyway
|
|
1091
|
+
// must refuse it -- so the drive sends it past the validator and expects failed.
|
|
1092
|
+
let answer;
|
|
1093
|
+
try {
|
|
1094
|
+
answer = await drive.connection.execute({ taskId, command: { type: "hold" } });
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
refuse("drive.command.rejected", "drive.command.hold", `execute rejected rather than answered: ${String(error)}`);
|
|
1098
|
+
}
|
|
1099
|
+
if (isRecord(answer) && answer.status !== "failed") {
|
|
1100
|
+
refuse("drive.command.handling", "drive.command.hold", `the provider applied hold on a completing task: a control on the contact belongs to in-progress or paused, and the adapter is the second gate`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1057
1103
|
}
|
|
1058
1104
|
}
|
|
1059
1105
|
if (typeof session?.close === "function") {
|
|
@@ -1080,6 +1126,20 @@ async function driveOneCall(drive) {
|
|
|
1080
1126
|
else if (latestTask().phase === "completing") {
|
|
1081
1127
|
await ended();
|
|
1082
1128
|
}
|
|
1129
|
+
// The record kept by the provider has the leg the host reported, wherever the provider restated it.
|
|
1130
|
+
if (mutedLeg !== undefined && latestTask() !== mutedLeg.task) {
|
|
1131
|
+
const history = latestTask().handlingHistory;
|
|
1132
|
+
if (isRecord(history) && Array.isArray(history.steps)) {
|
|
1133
|
+
const { at } = mutedLeg;
|
|
1134
|
+
const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
|
|
1135
|
+
if (leg === undefined) {
|
|
1136
|
+
refuse("drive.recordStep.history", "drive.recordStep", `the provider restated the task's record after the host reported a muted leg at ${at}, and the leg is not in it`);
|
|
1137
|
+
}
|
|
1138
|
+
else if (leg.mutedBy !== "host") {
|
|
1139
|
+
refuse("drive.recordStep.history", "drive.recordStep", `the record's muted leg at ${at} says mutedBy ${String(leg.mutedBy)}; the host reported host, and the record keeps the host's word`);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1083
1143
|
return found;
|
|
1084
1144
|
}
|
|
1085
1145
|
// A request goes not-requested -> awaiting-decision | granted; a commit goes granted ->
|
|
@@ -1159,9 +1219,12 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
|
|
|
1159
1219
|
envelopes.forEach((envelope, index) => found.push(...stream.apply(envelope, `envelopes[${index}]`)));
|
|
1160
1220
|
assertNoViolations(found, "The media follows the task");
|
|
1161
1221
|
}
|
|
1162
|
-
/**
|
|
1163
|
-
|
|
1164
|
-
|
|
1222
|
+
/**
|
|
1223
|
+
* A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`.
|
|
1224
|
+
* A softphone host states what its Mute does; a desk-phone or conversation host has no microphone and states nothing.
|
|
1225
|
+
*/
|
|
1226
|
+
export function stillHost(report = { online: true }, guarantees = {}, mute) {
|
|
1227
|
+
return { guarantees, ...(mute === undefined ? {} : { mute }), report: () => report, subscribe: () => () => undefined };
|
|
1165
1228
|
}
|
|
1166
1229
|
const usableLogin = (status) => status === "authenticated" || status === "refreshing";
|
|
1167
1230
|
/**
|
package/dist/validation.d.ts
CHANGED
|
@@ -51,6 +51,12 @@ export interface ReaderContext {
|
|
|
51
51
|
export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
52
52
|
export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
53
53
|
export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
54
|
+
/**
|
|
55
|
+
* What pressing Mute does on this host, stated on a softphone login and nowhere else: on a desk
|
|
56
|
+
* phone the microphone is the phone's, and off voice there is none. Neither the value nor its
|
|
57
|
+
* absence is inferred from what kind of application the host is.
|
|
58
|
+
*/
|
|
59
|
+
export declare function validateHostMute(mute: unknown, softphone: boolean, path?: string): ProtocolViolation[];
|
|
54
60
|
/**
|
|
55
61
|
* What a host promises. Presence is the guarantee, so a key declared `false` is refused: a
|
|
56
62
|
* promise withheld is an absent key, never a false one, exactly as a capability is.
|
package/dist/validation.js
CHANGED
|
@@ -96,7 +96,7 @@ const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
|
|
|
96
96
|
/** The capabilities each channel arm of `TaskCapabilities` declares, keyed off the type itself. */
|
|
97
97
|
const TASK_CAPABILITIES = {
|
|
98
98
|
voice: membersOf({
|
|
99
|
-
browsers: true, dispositions: true, custom: true, decline: true,
|
|
99
|
+
browsers: true, dispositions: true, custom: true, decline: true, hold: true,
|
|
100
100
|
endCall: true, connectBack: true, coldTransfer: true, warmTransfer: true, leadAssist: true, conference: true, recording: true,
|
|
101
101
|
}),
|
|
102
102
|
chat: membersOf({ browsers: true, dispositions: true, custom: true, decline: true, hold: true }),
|
|
@@ -574,7 +574,7 @@ const POLICY_SETTINGS = membersOf({ on: true, off: true, person: true });
|
|
|
574
574
|
const POLICY_KEYS = new Set([
|
|
575
575
|
...TASK_CAPABILITIES.voice, ...TASK_CAPABILITIES.chat, ...TASK_CAPABILITIES.email, "dial",
|
|
576
576
|
].filter(name => name !== "browsers" && name !== "dispositions" && name !== "custom"));
|
|
577
|
-
const PERSON_SETTABLE = /^(hold|
|
|
577
|
+
const PERSON_SETTABLE = /^(hold|skill:.+)$/;
|
|
578
578
|
const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
|
|
579
579
|
/** The level ids in force: the manifest's, or the defaults when the caller holds no manifest. */
|
|
580
580
|
const levelIds = (levels) => levels ?? DEFAULT_LEVEL_IDS;
|
|
@@ -751,6 +751,12 @@ function validateHandlingHistory(value, path, into) {
|
|
|
751
751
|
// Omitted while a leg is still running. Nought is a claim that it took no time.
|
|
752
752
|
into.require(isDurationSeconds(entry.seconds) && entry.seconds > 0, "task.handlingHistory.seconds", `${at}.seconds`, "seconds must be a positive whole number; omit it while the step is still running");
|
|
753
753
|
}
|
|
754
|
+
// A muted entry carries whose the silence was, as the host reported it; no other step has it.
|
|
755
|
+
if (entry.step === "muted")
|
|
756
|
+
into.oneOf(entry.mutedBy, MUTED_BY, "task.handlingHistory.mutedBy", `${at}.mutedBy`);
|
|
757
|
+
else if (HANDLING_STEPS.includes(entry.step)) {
|
|
758
|
+
into.require(entry.mutedBy === undefined, "task.handlingHistory.mutedBy.unexpected", `${at}.mutedBy`, "only a muted step says who silenced the microphone");
|
|
759
|
+
}
|
|
754
760
|
if (entry.by !== undefined) {
|
|
755
761
|
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");
|
|
756
762
|
// On `queued` nobody takes part, so there is nothing to name.
|
|
@@ -992,8 +998,13 @@ function validateTaskInto(task, context, path, into) {
|
|
|
992
998
|
for (const [name, declared] of Object.entries(capabilities)) {
|
|
993
999
|
if (declared === undefined)
|
|
994
1000
|
continue;
|
|
995
|
-
if (!
|
|
1001
|
+
if (!allowed.includes(name)) {
|
|
1002
|
+
// A name another channel owns is a channel error; one no channel owns is not a capability at
|
|
1003
|
+
// all -- mute among them, since the microphone is the host's and no provider declares it.
|
|
1004
|
+
const known = Object.values(TASK_CAPABILITIES).some(names => names.includes(name));
|
|
1005
|
+
into.add(known ? "task.capability.channel" : "task.capability.unknown", `${path}.capabilities.${name}`, known ? `a ${context.channel} task may not declare ${name}` : `${name} is not a capability a provider declares on any channel`);
|
|
996
1006
|
continue;
|
|
1007
|
+
}
|
|
997
1008
|
// A control that dials needs the manifest to have said how a dial ends, or its outcome has no words.
|
|
998
1009
|
if (DIALLING_CAPABILITIES.includes(name) && context.dialOutcomesDeclared === false) {
|
|
999
1010
|
into.add("task.capability.dialOutcomes.required", `${path}.capabilities.${name}`, `${name} dials, and the manifest declares no dialOutcomes to say how a dial ends`);
|
|
@@ -1538,7 +1549,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1538
1549
|
// Results. A result crosses the same boundary a snapshot does, from an adapter that may be
|
|
1539
1550
|
// compiled against another version, and Omni shows the agent what it says.
|
|
1540
1551
|
// ---------------------------------------------------------------------------
|
|
1541
|
-
const PREFERENCE_ID = /^(hold|
|
|
1552
|
+
const PREFERENCE_ID = /^(hold|skill:.+)$/;
|
|
1542
1553
|
/** The choices left to the person, each with where it stands. */
|
|
1543
1554
|
function validatePreferencesInto(value, path, into, levels) {
|
|
1544
1555
|
if (!Array.isArray(value)) {
|
|
@@ -1552,7 +1563,7 @@ function validatePreferencesInto(value, path, into, levels) {
|
|
|
1552
1563
|
into.add("preference.shape", at, "each preference must be an object");
|
|
1553
1564
|
return;
|
|
1554
1565
|
}
|
|
1555
|
-
if (into.require(typeof preference.id === "string" && PREFERENCE_ID.test(preference.id), "preference.id", `${at}.id`, "a preference is hold
|
|
1566
|
+
if (into.require(typeof preference.id === "string" && PREFERENCE_ID.test(preference.id), "preference.id", `${at}.id`, "a preference is hold or skill:<id>: nothing else is the person's to set")) {
|
|
1556
1567
|
if (seen.has(preference.id))
|
|
1557
1568
|
into.add("preference.unique", `${at}.id`, `duplicate preference: ${preference.id}`);
|
|
1558
1569
|
seen.add(preference.id);
|
|
@@ -1579,7 +1590,7 @@ function validateTeamPoliciesInto(value, path, into, levels) {
|
|
|
1579
1590
|
continue;
|
|
1580
1591
|
}
|
|
1581
1592
|
if (into.oneOf(policy.setting, POLICY_SETTINGS, "team.policy.setting", `${at}.setting`)) {
|
|
1582
|
-
into.require(policy.setting !== "person" || PERSON_SETTABLE.test(key), "team.policy.person", `${at}.setting`, `${key} is the team's, on or off; only hold
|
|
1593
|
+
into.require(policy.setting !== "person" || PERSON_SETTABLE.test(key), "team.policy.person", `${at}.setting`, `${key} is the team's, on or off; only hold and skills may be left to the person`);
|
|
1583
1594
|
}
|
|
1584
1595
|
validateResolvedInto(policy, "team.policy", at, levels, into);
|
|
1585
1596
|
into.require(policy.setBy !== "person", "team.policy.setBy", `${at}.setBy`, "a team policy is not set by a person");
|
|
@@ -1621,6 +1632,23 @@ function validateUnavailable(value, rule, path, into) {
|
|
|
1621
1632
|
* hands the adapter.
|
|
1622
1633
|
*/
|
|
1623
1634
|
const HOST_GUARANTEES = membersOf({ browserUrlVisibility: true, personConsent: true });
|
|
1635
|
+
const MUTED_BY = membersOf({ host: true, station: true });
|
|
1636
|
+
const HOST_MUTES = membersOf({ stream: true, station: true });
|
|
1637
|
+
/**
|
|
1638
|
+
* What pressing Mute does on this host, stated on a softphone login and nowhere else: on a desk
|
|
1639
|
+
* phone the microphone is the phone's, and off voice there is none. Neither the value nor its
|
|
1640
|
+
* absence is inferred from what kind of application the host is.
|
|
1641
|
+
*/
|
|
1642
|
+
export function validateHostMute(mute, softphone, path = "host.mute") {
|
|
1643
|
+
const into = new Collector();
|
|
1644
|
+
if (mute === undefined) {
|
|
1645
|
+
into.require(!softphone, "host.mute.required", path, "a softphone login's host states what its Mute does: stream or station");
|
|
1646
|
+
}
|
|
1647
|
+
else if (into.require(softphone, "host.mute.unexpected", path, "only a softphone login's host holds a microphone to mute; a desk phone's is the phone's, and a conversation has none")) {
|
|
1648
|
+
into.oneOf(mute, HOST_MUTES, "host.mute", path);
|
|
1649
|
+
}
|
|
1650
|
+
return into.violations;
|
|
1651
|
+
}
|
|
1624
1652
|
/**
|
|
1625
1653
|
* What a host promises. Presence is the guarantee, so a key declared `false` is refused: a
|
|
1626
1654
|
* promise withheld is an absent key, never a false one, exactly as a capability is.
|
|
@@ -1654,6 +1682,12 @@ export function validateHandlingReport(report, path = "handlingReport", manifest
|
|
|
1654
1682
|
into.require(isTaskId(report.taskId), "handlingReport.taskId", `${path}.taskId`, "a report names the task");
|
|
1655
1683
|
into.oneOf(report.step, HANDLING_STEPS, "handlingReport.step", `${path}.step`);
|
|
1656
1684
|
into.timestamp(report.at, "handlingReport.at", `${path}.at`);
|
|
1685
|
+
// A muted leg says whose the silence was; no other leg has anyone to name for it.
|
|
1686
|
+
if (report.step === "muted")
|
|
1687
|
+
into.oneOf(report.mutedBy, MUTED_BY, "handlingReport.mutedBy", `${path}.mutedBy`);
|
|
1688
|
+
else if (HANDLING_STEPS.includes(report.step)) {
|
|
1689
|
+
into.require(report.mutedBy === undefined, "handlingReport.mutedBy.unexpected", `${path}.mutedBy`, "only a muted leg says who silenced the microphone");
|
|
1690
|
+
}
|
|
1657
1691
|
if (report.seconds !== undefined) {
|
|
1658
1692
|
into.require(isDurationSeconds(report.seconds) && report.seconds > 0, "handlingReport.seconds", `${path}.seconds`, "seconds must be a positive whole number; omit it rather than report nought");
|
|
1659
1693
|
}
|
|
@@ -1669,6 +1703,15 @@ export function validateHandlingReport(report, path = "handlingReport", manifest
|
|
|
1669
1703
|
}
|
|
1670
1704
|
return into.violations;
|
|
1671
1705
|
}
|
|
1706
|
+
/** A silenced device says who silenced it; one that flows, or whose flow the host cannot know, says nothing. */
|
|
1707
|
+
function validateMutedBy(device, rule, path, into) {
|
|
1708
|
+
if (device.flowing === false) {
|
|
1709
|
+
into.oneOf(device.mutedBy, MUTED_BY, `${rule}.mutedBy`, `${path}.mutedBy`);
|
|
1710
|
+
}
|
|
1711
|
+
else {
|
|
1712
|
+
into.require(device.mutedBy === undefined, `${rule}.mutedBy.unexpected`, `${path}.mutedBy`, "mutedBy says who silenced a device that is not flowing; a flowing one names nobody");
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1672
1715
|
export function validateHostReport(report, path = "host") {
|
|
1673
1716
|
const into = new Collector();
|
|
1674
1717
|
if (!isPlainObject(report)) {
|
|
@@ -1690,6 +1733,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
1690
1733
|
else if (input.status === "available") {
|
|
1691
1734
|
into.require(typeof input.localAudio === "object" && input.localAudio !== null, "host.audio.input.localAudio", `${at}.localAudio`, "a ready input carries the captured microphone");
|
|
1692
1735
|
into.require(typeof input.flowing === "boolean", "host.audio.input.flowing", `${at}.flowing`, "a ready input says whether audio is flowing through it");
|
|
1736
|
+
validateMutedBy(input, "host.audio.input", at, into);
|
|
1693
1737
|
into.require(input.failure === undefined, "host.audio.input.failure.unexpected", `${at}.failure`, "a ready input carries no failure");
|
|
1694
1738
|
into.require(input.reason === undefined, "host.audio.input.reason.unexpected", `${at}.reason`, "a ready input has no reason to be unavailable");
|
|
1695
1739
|
}
|
|
@@ -1698,6 +1742,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
1698
1742
|
validateUnavailable(input, "host.audio.input", at, into);
|
|
1699
1743
|
into.require(input.localAudio === undefined, "host.audio.input.localAudio.unexpected", `${at}.localAudio`, "an unavailable input carries no microphone");
|
|
1700
1744
|
into.require(input.flowing === undefined, "host.audio.input.flowing.unexpected", `${at}.flowing`, "an unavailable input has nothing to flow");
|
|
1745
|
+
into.require(input.mutedBy === undefined, "host.audio.input.mutedBy.unexpected", `${at}.mutedBy`, "an unavailable input was not silenced; it is absent");
|
|
1701
1746
|
}
|
|
1702
1747
|
else {
|
|
1703
1748
|
into.add("host.audio.input.status", `${at}.status`, `an input is available or unavailable, not ${String(input.status)}`);
|
|
@@ -1708,12 +1753,19 @@ export function validateHostReport(report, path = "host") {
|
|
|
1708
1753
|
into.add("host.audio.output.shape", out, "audio carries its output");
|
|
1709
1754
|
}
|
|
1710
1755
|
else if (output.status === "available") {
|
|
1756
|
+
// Whether the speaker is silenced is stated only where the host can know it: a browser mostly cannot, and omits it.
|
|
1757
|
+
if (output.flowing !== undefined) {
|
|
1758
|
+
into.require(typeof output.flowing === "boolean", "host.audio.output.flowing", `${out}.flowing`, "flowing says whether audio reaches the speaker, when the host can know");
|
|
1759
|
+
}
|
|
1760
|
+
validateMutedBy(output, "host.audio.output", out, into);
|
|
1711
1761
|
into.require(output.failure === undefined, "host.audio.output.failure.unexpected", `${out}.failure`, "a ready output carries no failure");
|
|
1712
1762
|
into.require(output.reason === undefined, "host.audio.output.reason.unexpected", `${out}.reason`, "a ready output has no reason to be unavailable");
|
|
1713
1763
|
}
|
|
1714
1764
|
else if (output.status === "unavailable") {
|
|
1715
1765
|
into.oneOf(output.reason, HOST_OUTPUT_REASONS, "host.audio.output.reason", `${out}.reason`);
|
|
1716
1766
|
validateUnavailable(output, "host.audio.output", out, into);
|
|
1767
|
+
into.require(output.flowing === undefined, "host.audio.output.flowing.unexpected", `${out}.flowing`, "an unavailable output has nothing to flow");
|
|
1768
|
+
into.require(output.mutedBy === undefined, "host.audio.output.mutedBy.unexpected", `${out}.mutedBy`, "an unavailable output was not silenced; it is absent");
|
|
1717
1769
|
}
|
|
1718
1770
|
else {
|
|
1719
1771
|
into.add("host.audio.output.status", `${out}.status`, `an output is available or unavailable, not ${String(output.status)}`);
|
|
@@ -1754,9 +1806,6 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1754
1806
|
}
|
|
1755
1807
|
const dial = (rule) => into.filled(command.dialId, rule, `${path}.dialId`, "a command that dials carries the host's dialId");
|
|
1756
1808
|
switch (type) {
|
|
1757
|
-
case "mute":
|
|
1758
|
-
into.require(typeof command.muted === "boolean", "command.mute.muted", `${path}.muted`, "mute says whether the agent is muted");
|
|
1759
|
-
break;
|
|
1760
1809
|
case "call":
|
|
1761
1810
|
dial("command.call.dialId");
|
|
1762
1811
|
break;
|
|
@@ -1831,6 +1880,10 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1831
1880
|
return true;
|
|
1832
1881
|
};
|
|
1833
1882
|
const inPhase = (rule, ...phases) => into.require(phases.includes(String(task.phase)), rule, path, `${String(type)} belongs to ${phases.join(" or ")}, and the task is ${String(task.phase)}`);
|
|
1883
|
+
// A control on the contact acts on a contact being handled. Before in-progress there is nothing
|
|
1884
|
+
// to act on yet, and in completing the handling has ended: a call with nobody on it, a
|
|
1885
|
+
// conversation closed. What a capability offers, the phase decides whether there is anything to use it on.
|
|
1886
|
+
const handling = () => inPhase("command.phase.handling", "in-progress", "paused");
|
|
1834
1887
|
const onCall = Array.isArray(task.onCall) ? task.onCall.filter(isPlainObject) : [];
|
|
1835
1888
|
switch (type) {
|
|
1836
1889
|
case "answer":
|
|
@@ -1844,25 +1897,26 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1844
1897
|
case "call":
|
|
1845
1898
|
inPhase("command.phase.preview", "preview");
|
|
1846
1899
|
break;
|
|
1847
|
-
case "mute":
|
|
1848
|
-
offered("mute");
|
|
1849
|
-
break;
|
|
1850
1900
|
case "hold":
|
|
1851
1901
|
case "resume":
|
|
1852
1902
|
case "pause":
|
|
1853
1903
|
offered("hold");
|
|
1904
|
+
handling();
|
|
1854
1905
|
break;
|
|
1855
1906
|
case "end-call":
|
|
1856
1907
|
offered("endCall");
|
|
1908
|
+
handling();
|
|
1857
1909
|
break;
|
|
1858
1910
|
case "recording":
|
|
1859
1911
|
offered("recording");
|
|
1912
|
+
handling();
|
|
1860
1913
|
break;
|
|
1861
1914
|
case "connect-back":
|
|
1862
1915
|
offered("connectBack");
|
|
1863
1916
|
inPhase("command.phase.completing", "completing");
|
|
1864
1917
|
break;
|
|
1865
1918
|
case "transfer":
|
|
1919
|
+
handling();
|
|
1866
1920
|
if (command.action === "cold")
|
|
1867
1921
|
offered("coldTransfer");
|
|
1868
1922
|
else if (command.action === "warm")
|
|
@@ -1872,6 +1926,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1872
1926
|
}
|
|
1873
1927
|
break;
|
|
1874
1928
|
case "lead-assist":
|
|
1929
|
+
handling();
|
|
1875
1930
|
if (command.action === "request" || command.action === "cancel") {
|
|
1876
1931
|
offered("leadAssist");
|
|
1877
1932
|
if (command.action === "cancel") {
|
|
@@ -1883,6 +1938,7 @@ export function validateTaskCommand(command, task, path = "command") {
|
|
|
1883
1938
|
}
|
|
1884
1939
|
break;
|
|
1885
1940
|
case "conference":
|
|
1941
|
+
handling();
|
|
1886
1942
|
if (offered("conference") && command.action === "remove") {
|
|
1887
1943
|
const others = onCall.filter(entry => entry.role !== "agent");
|
|
1888
1944
|
if (command.party === true) {
|
package/guide.md
CHANGED
|
@@ -271,12 +271,16 @@ type AuthenticationFailure = {
|
|
|
271
271
|
type HostAudioUnavailableReason = "no-device" | "denied" | "not-asked" | "in-use" | "lost";
|
|
272
272
|
type HostOutputUnavailableReason = "no-device" | "lost";
|
|
273
273
|
|
|
274
|
+
type MutedBy = "host" | "station";
|
|
275
|
+
|
|
276
|
+
type HostMute = "stream" | "station";
|
|
277
|
+
|
|
274
278
|
type HostAudioInput =
|
|
275
|
-
| { status: "available"; localAudio: MediaStream; flowing:
|
|
279
|
+
| ({ status: "available"; localAudio: MediaStream } & ({ flowing: true; mutedBy?: never } | { flowing: false; mutedBy: MutedBy }))
|
|
276
280
|
| { status: "unavailable"; reason: HostAudioUnavailableReason; failure: ProtocolFailure };
|
|
277
281
|
|
|
278
282
|
type HostAudioOutput =
|
|
279
|
-
| { status: "available" }
|
|
283
|
+
| ({ status: "available" } & ({ flowing?: true; mutedBy?: never } | { flowing: false; mutedBy: MutedBy }))
|
|
280
284
|
| { status: "unavailable"; reason: HostOutputUnavailableReason; failure: ProtocolFailure };
|
|
281
285
|
|
|
282
286
|
type UrlVisibility = "full" | "domain" | "hidden";
|
|
@@ -296,6 +300,7 @@ type HostGuarantees = {
|
|
|
296
300
|
|
|
297
301
|
type Host = {
|
|
298
302
|
guarantees: HostGuarantees;
|
|
303
|
+
mute?: HostMute;
|
|
299
304
|
report(): HostReport;
|
|
300
305
|
subscribe(listener: (report: HostReport) => void): Unsubscribe;
|
|
301
306
|
};
|
|
@@ -361,7 +366,7 @@ type AuthenticationSession = {
|
|
|
361
366
|
### Provider state
|
|
362
367
|
|
|
363
368
|
```ts
|
|
364
|
-
type PreferenceId = "hold" |
|
|
369
|
+
type PreferenceId = "hold" | `skill:${string}`;
|
|
365
370
|
|
|
366
371
|
type SetBy = Level | "provider";
|
|
367
372
|
|
|
@@ -381,7 +386,10 @@ type SetPreferenceRequest =
|
|
|
381
386
|
| { id: PreferenceId; enabled: boolean }
|
|
382
387
|
| { id: PreferenceId; inherit: true };
|
|
383
388
|
|
|
384
|
-
type HandlingReport = { taskId: TaskId;
|
|
389
|
+
type HandlingReport = { taskId: TaskId; at: IsoTimestamp } & (
|
|
390
|
+
| { step: "muted"; mutedBy: MutedBy }
|
|
391
|
+
| { step: Exclude<HandlingStep, "muted">; mutedBy?: never }
|
|
392
|
+
) & (
|
|
385
393
|
| { ended: true; seconds: DurationSeconds }
|
|
386
394
|
| { ended?: never; seconds?: DurationSeconds }
|
|
387
395
|
);
|
|
@@ -484,7 +492,6 @@ type TaskCapabilities<C extends Channel = Channel> =
|
|
|
484
492
|
C extends "voice"
|
|
485
493
|
? SharedTaskCapabilities & {
|
|
486
494
|
decline?: Lockable<true>;
|
|
487
|
-
mute?: Lockable<true>;
|
|
488
495
|
hold?: Lockable<true>;
|
|
489
496
|
endCall?: Lockable<true>;
|
|
490
497
|
connectBack?: Lockable<true>;
|
|
@@ -592,6 +599,7 @@ type TaskHandlingStep = {
|
|
|
592
599
|
destinationId?: string;
|
|
593
600
|
seconds?: DurationSeconds;
|
|
594
601
|
by?: UserId;
|
|
602
|
+
mutedBy?: MutedBy;
|
|
595
603
|
};
|
|
596
604
|
|
|
597
605
|
type TaskCompletion =
|
|
@@ -692,7 +700,6 @@ const TASK_COMMAND_NAMES = {
|
|
|
692
700
|
"answer",
|
|
693
701
|
"decline",
|
|
694
702
|
"call",
|
|
695
|
-
"mute",
|
|
696
703
|
"hold",
|
|
697
704
|
"resume",
|
|
698
705
|
"end-call",
|
|
@@ -716,7 +723,6 @@ type VoiceTaskCommand =
|
|
|
716
723
|
| { type: "answer" }
|
|
717
724
|
| { type: "decline" }
|
|
718
725
|
| { type: "call"; dialId: DialId }
|
|
719
|
-
| { type: "mute"; muted: boolean }
|
|
720
726
|
| { type: "hold" }
|
|
721
727
|
| { type: "resume" }
|
|
722
728
|
| { type: "end-call" }
|
|
@@ -767,7 +773,7 @@ type TaskCommandResult =
|
|
|
767
773
|
### Who decides what an agent may do
|
|
768
774
|
|
|
769
775
|
An agent desk has two managers, not one. **The queue** — a process, a work type — is owned by a
|
|
770
|
-
process manager and **allows** a set of capabilities: hold,
|
|
776
|
+
process manager and **allows** a set of capabilities: hold, connect back, new call, conference,
|
|
771
777
|
whether the number is visible, the actions it offers, the skills it needs. **The people** are
|
|
772
778
|
managed through the organisation's structure — a team, a location, the organisation itself, in
|
|
773
779
|
whatever combination the structure defines for a person — and **decide** per capability within
|
|
@@ -801,8 +807,8 @@ against them, not only the sign-in. What the wire carries is the resolution:
|
|
|
801
807
|
**On a task, a control the queue could allow may stand locked in its place.** `Task.capabilities`
|
|
802
808
|
is the effective set. What the queue does not allow is absent and nothing is shown. What the queue
|
|
803
809
|
allows and a level above the person locked is present as `{ lockedBy, reason? }` where the control's
|
|
804
|
-
value would be — `
|
|
805
|
-
renders that control disabled, saying who decided, so an agent who cannot press
|
|
810
|
+
value would be — `recording: { lockedBy: "team", reason: "Nobody on this team records" }` — and Omni
|
|
811
|
+
renders that control disabled, saying who decided, so an agent who cannot press Record knows whether
|
|
806
812
|
to ask their lead or their site. `lockedBy` is the discriminant: a value that carries it is the
|
|
807
813
|
lock, so nothing that can be locked — a directory, a number — may carry that key itself. A
|
|
808
814
|
contact's number and email are the same, since each identifies a person: where the queue says
|
|
@@ -813,14 +819,14 @@ controls — is content, and is never locked.
|
|
|
813
819
|
|
|
814
820
|
**A lead sets the team's policy from their roster.** A login that declares
|
|
815
821
|
`capabilities.team.policyControl` may `executeTeamPolicy({ type: "set", capability, setting })`
|
|
816
|
-
with `on`, `off`, or `person`, for any task control, `dial`, or a skill — and only `hold
|
|
822
|
+
with `on`, `off`, or `person`, for any task control, `dial`, or a skill — and only `hold`
|
|
817
823
|
and skills may be `person`; connect back and new call are the team's, on or off, within what the queue
|
|
818
824
|
allows. The roster carries `policies` for such a login: every policy as it stands, who set it, and
|
|
819
825
|
`lockedBy` where a level above the team made it theirs to keep, which the lead sees and cannot
|
|
820
826
|
change — `executeTeamPolicy` on it answers `failed` with `omni.capability-not-enabled`.
|
|
821
827
|
|
|
822
828
|
**What the team left to the person is the person's, and the provider keeps it.** The login's
|
|
823
|
-
`capabilities.preferences` lists every preference the person may hold — `hold`,
|
|
829
|
+
`capabilities.preferences` lists every preference the person may hold — `hold`, a skill —
|
|
824
830
|
with where it stands and who set it: `setBy: "team"` while they inherit the team's default,
|
|
825
831
|
`"person"` once they have set their own, `"provider"` where no level has said anything. Nothing
|
|
826
832
|
is hidden for want of a row, and a preference a level above has since locked is listed with
|
|
@@ -831,8 +837,10 @@ inherit: true }` to give it up and inherit again — answered `applied` and repu
|
|
|
831
837
|
person's preference from their own screen, which arrives the same way. A preference is keyed by
|
|
832
838
|
the capability's own name because it is the same capability at another level: effective in
|
|
833
839
|
`Task.capabilities`, set for the team in `policies`, left to the person in `preferences`. The
|
|
834
|
-
command `
|
|
835
|
-
at all, and a host renders it in its settings, never as the button on a call.
|
|
840
|
+
command `hold` acts on one call; the preference `hold` says whether the person wants the control
|
|
841
|
+
at all, and a host renders it in its settings, never as the button on a call. Mute is in none of
|
|
842
|
+
these ladders: the microphone is the host's, and nobody on the provider's side allows, locks or
|
|
843
|
+
keeps a choice about it. See **The station is the host's**.
|
|
836
844
|
|
|
837
845
|
## Breaks
|
|
838
846
|
|
|
@@ -2003,7 +2011,7 @@ surface in one place, and what obliges an adapter to implement each one.
|
|
|
2003
2011
|
| `executeTeamLeadAssist(command)` | The login declares `capabilities.team.leadAssistControl`. |
|
|
2004
2012
|
| `executeTeamMonitor(command)` | The login declares `capabilities.team.monitorControl`. |
|
|
2005
2013
|
| `setPreference(request)` | The login declares `capabilities.preferences`: the person's choice has to have somewhere to go. |
|
|
2006
|
-
| `recordStep(report)` |
|
|
2014
|
+
| `recordStep(report)` | The manifest lists `softphone` among its `phones`. On a softphone the host mutes its own microphone on any call, and the provider's record has to have somewhere to take that leg; a desk phone's microphone is the phone's. See **The host records what it performs**. |
|
|
2007
2015
|
| `executeTeamPolicy(command)` | The login declares `capabilities.team.policyControl`. |
|
|
2008
2016
|
| `openMedia(request)` | The manifest lists `softphone` among its `phones`. On a softphone the call's audio lands in Omni, so the adapter has to open it; a platform of desk phones alone never does. |
|
|
2009
2017
|
|
|
@@ -2187,7 +2195,7 @@ into a new session.
|
|
|
2187
2195
|
`task-offered` introduces a new task and does not imply acceptance.
|
|
2188
2196
|
|
|
2189
2197
|
`Task<C>` is channel-discriminated. For example, `Task<"email">` accepts
|
|
2190
|
-
`browsers` and `dispositions`, but rejects voice-only controls such as `
|
|
2198
|
+
`browsers` and `dispositions`, but rejects voice-only controls such as `hold` and `endCall` at compile
|
|
2191
2199
|
time. Runtime conformance checks also require the task channel to match its provider manifest.
|
|
2192
2200
|
|
|
2193
2201
|
| Field | Contract |
|
|
@@ -2464,9 +2472,11 @@ history — until the task's media ends, and a hold neither pauses nor resets it
|
|
|
2464
2472
|
duration is the `held` entry's `seconds`, and a desk that restarts its counter on resume is
|
|
2465
2473
|
counting the wrong thing.
|
|
2466
2474
|
|
|
2467
|
-
`muted` is there because
|
|
2468
|
-
|
|
2469
|
-
account of a period the agent could not be heard.
|
|
2475
|
+
`muted` is there because the mute is the host's and never the provider's — see **The station is
|
|
2476
|
+
the host's** — so without a step the provider, which alone keeps the task's record, would have no
|
|
2477
|
+
account of a period the agent could not be heard. Each `muted` entry carries `mutedBy`, as the host
|
|
2478
|
+
reported it: `host` for the agent's own Mute, `station` for a headset or system mute the host
|
|
2479
|
+
observed. No other step has it (`task.handlingHistory.mutedBy`, `.mutedBy.unexpected`).
|
|
2470
2480
|
|
|
2471
2481
|
**A step that dialled says which dial and where.** `transferred`, `conferenced` and `unanswered`
|
|
2472
2482
|
each end a dial, so the entry carries the `destinationId` it went to and, where a host placed it, the
|
|
@@ -2522,8 +2532,8 @@ added here as a need is shown, not invented ahead of one.
|
|
|
2522
2532
|
|
|
2523
2533
|
#### The host records what it performs
|
|
2524
2534
|
|
|
2525
|
-
`muted` is the one leg
|
|
2526
|
-
would have a hole exactly there. So the host reports it, through `recordStep`, and the provider
|
|
2535
|
+
`muted` is the one leg the host performs rather than the provider, and a record kept by the
|
|
2536
|
+
provider would have a hole exactly there. So the host reports it, through `recordStep`, and the provider
|
|
2527
2537
|
writes it into its record as it writes every other leg:
|
|
2528
2538
|
|
|
2529
2539
|
```ts
|
|
@@ -2531,9 +2541,9 @@ declare const connection: Connection<"voice">;
|
|
|
2531
2541
|
declare const taskId: TaskId;
|
|
2532
2542
|
declare const at: IsoTimestamp;
|
|
2533
2543
|
|
|
2534
|
-
void connection.recordStep?.({ taskId, step: "muted", at }); // the moment the agent mutes
|
|
2535
|
-
void connection.recordStep?.({ taskId, step: "muted", at, seconds: 15 }); // still running, if the host chooses to say
|
|
2536
|
-
void connection.recordStep?.({ taskId, step: "muted", at, seconds: 42, ended: true }); // the moment they unmute
|
|
2544
|
+
void connection.recordStep?.({ taskId, step: "muted", at, mutedBy: "host" }); // the moment the agent mutes
|
|
2545
|
+
void connection.recordStep?.({ taskId, step: "muted", at, mutedBy: "host", seconds: 15 }); // still running, if the host chooses to say
|
|
2546
|
+
void connection.recordStep?.({ taskId, step: "muted", at, mutedBy: "host", seconds: 42, ended: true }); // the moment they unmute
|
|
2537
2547
|
```
|
|
2538
2548
|
|
|
2539
2549
|
The entry is keyed by `step` and `at`, so every report about one leg names the same instant. The
|
|
@@ -2546,7 +2556,11 @@ leg, when it began and when it ended, and `validateHandlingReport(report, path,
|
|
|
2546
2556
|
a running one it was never asked for (`handlingReport.running.unexpected`). What a provider that
|
|
2547
2557
|
did ask for them forwards upstream, and how often, is its own business. The step appears in
|
|
2548
2558
|
`handlingHistory` when the *provider* publishes it: Omni never writes the record itself.
|
|
2549
|
-
`recordStep` is required of
|
|
2559
|
+
`recordStep` is required of every softphone login's connection, since every call on a softphone
|
|
2560
|
+
can be muted by the host, and answers `recorded`. A `muted` report says whose the silence was,
|
|
2561
|
+
`mutedBy: "host"` or `"station"`, and no other report has the word (`handlingReport.mutedBy`,
|
|
2562
|
+
`.mutedBy.unexpected`). On a desk phone the microphone is the phone's:
|
|
2563
|
+
the host mutes nothing and records nothing.
|
|
2550
2564
|
|
|
2551
2565
|
**A host-performed leg still open when the task's media ends, or the task ends, is ended by the
|
|
2552
2566
|
host at that instant** — `ended: true`, `seconds` to the end, the same `at` — since a provider
|
|
@@ -2762,12 +2776,16 @@ const taskCapabilities = {
|
|
|
2762
2776
|
} satisfies Pick<Task<"voice">, "channel" | "capabilities" | "browsers">;
|
|
2763
2777
|
```
|
|
2764
2778
|
|
|
2779
|
+
A capability says the control may be offered; the task's phase says whether there is anything to use
|
|
2780
|
+
it on. Every control below that acts on the call or the conversation -- everything but `decline`,
|
|
2781
|
+
`connectBack` and `dispositions` -- is offered only while the task is `in-progress` or `paused`.
|
|
2782
|
+
See **Which commands need a capability**.
|
|
2783
|
+
|
|
2765
2784
|
### Voice capabilities
|
|
2766
2785
|
|
|
2767
2786
|
| Capability | Omni UI | Contract |
|
|
2768
2787
|
| --- | --- | --- |
|
|
2769
2788
|
| `decline` | Pending-task button: Decline | The provider can decline a pending voice offer. Omni shows it only when provisioning also permits declining. |
|
|
2770
|
-
| `mute` | Primary toggle: Mute | Omni may mute and unmute the agent's outbound audio. |
|
|
2771
2789
|
| `hold` | Primary toggle: Hold | Omni may issue voice-task `hold` and `resume` commands. |
|
|
2772
2790
|
| `endCall` | Primary button: End call | Omni may end the whole call: everyone leaves, the media ends, and the task stays for its wrap-up. See **Ending a call, and removing one person from it**. |
|
|
2773
2791
|
| `connectBack` | Completing-task button: Connect back | Omni may have the provider connect the agent back to the task's party while the task is `completing`, returning it to `in-progress` on the same task. Not offered where there is no `completing` window: `provider-automatic` with a zero allowance disposes at provider end. See **Connecting back during completion**. |
|
|
@@ -3782,7 +3800,9 @@ what failed, and reports. It never decides for the adapter what a missing microp
|
|
|
3782
3800
|
**The host also guarantees, and a promise the provider cannot see is not one it can rely on.**
|
|
3783
3801
|
Two of this contract's obligations fall on the host rather than the provider — honouring a task
|
|
3784
3802
|
browser's `urlVisibility`, and taking a `consent` offer only on the person's own press — and more
|
|
3785
|
-
than one desk speaks this contract
|
|
3803
|
+
than one desk speaks this contract: a browser and a native application differ in what they can
|
|
3804
|
+
reach, and a provider never branches on which it is talking to, only on what it has declared —
|
|
3805
|
+
here, and in `host.mute` (see **The station is the host's**). So `ConnectContext.host.guarantees` says which promises the
|
|
3786
3806
|
connected host makes, declared once per connection, presence being the guarantee exactly as it is
|
|
3787
3807
|
the permission everywhere else:
|
|
3788
3808
|
|
|
@@ -3798,8 +3818,8 @@ refuses a false one, as it refuses a name this contract does not list.
|
|
|
3798
3818
|
| --- | --- |
|
|
3799
3819
|
| `online` | Whether the host has a network interface up. Not a claim that anything is reachable — the adapter knows whether it can reach its own platform far better than the host does — so `false` is a reason not to go ready and `true` is not a reason to. |
|
|
3800
3820
|
| `audio` | Present on a voice connection, absent where there is no audio. |
|
|
3801
|
-
| `audio.input` | `available` with `localAudio` — the microphone as captured, the same stream `openMedia` receives — and `flowing`, false while
|
|
3802
|
-
| `audio.output` | `available`,
|
|
3821
|
+
| `audio.input` | `available` with `localAudio` — the microphone as captured, the same stream `openMedia` receives — and `flowing`, false while no audio moves through it, when `mutedBy` says who stopped it: `host`, the host's own Mute on a host that mutes the station, or `station`, a slider on the headset or the operating system, which the host observed and did not do. An adapter treats `host` as the agent's act on a call and `station` as a condition of the station. `unavailable` with `reason`, since each wants a different fix from the agent: `no-device`; `denied`; `not-asked`, which a host that asks at connect never publishes; `in-use`, a device present and permitted that another application holds — on an agent desktop the commonest of all; `lost`, a capture that ended. A host decides the reason from the devices before the error name: a browser can report a permission error on a machine with no microphone at all, and "grant permission" is the wrong instruction for an agent who needs to plug one in. `failure` carries the words Omni showed them. |
|
|
3822
|
+
| `audio.output` | `available`, with `flowing` where the host can know whether audio reaches the speaker — a browser mostly cannot, and omits it; a native host reads the endpoint — false while it is silenced, when `mutedBy` says who did it, as on input. Or `unavailable` with `reason` — `no-device`, or `lost` for one removed — and `failure`: an agent who cannot hear is as unable to take a call as one who cannot speak. |
|
|
3803
3823
|
|
|
3804
3824
|
Omni republishes the report whenever it changes — a permission granted late, a headset unplugged,
|
|
3805
3825
|
a network gone — and it publishes a state, not a flicker: a change that resolves within moments is
|
|
@@ -3811,7 +3831,8 @@ The host's own obligations here — asking at connect, never publishing `not-ask
|
|
|
3811
3831
|
publishing a state and not a flicker — are Omni's tests' to hold. `exerciseAdapter` holds the
|
|
3812
3832
|
other side: it validates the shape of whatever host a test hands the adapter, requires `audio` on
|
|
3813
3833
|
a softphone login and none elsewhere -- not on a desk phone, not off voice
|
|
3814
|
-
(`context.host.audio.required` / `.unexpected`)
|
|
3834
|
+
(`context.host.audio.required` / `.unexpected`), holds `host.mute` to the same line (`host.mute.required`
|
|
3835
|
+
/ `.unexpected`, `host.mute` for a third word) -- and refuses a voice adapter that never asked the
|
|
3815
3836
|
host anything (`connection.host.consulted`).
|
|
3816
3837
|
|
|
3817
3838
|
### Capacity around setup
|
|
@@ -3952,8 +3973,8 @@ unions are declared under **Shapes**.
|
|
|
3952
3973
|
|
|
3953
3974
|
**A toggle carries the state it wants, not a flip.** Inverting whatever is found cannot converge
|
|
3954
3975
|
with a stale view: a flip against a state the provider has already changed turns something on and
|
|
3955
|
-
then off again. `
|
|
3956
|
-
|
|
3976
|
+
then off again. A custom `toggle` control therefore carries its own boolean. `hold` and `resume`,
|
|
3977
|
+
`pause` and `resume` need no flag, being pairs rather than toggles.
|
|
3957
3978
|
|
|
3958
3979
|
**`complete` sends a disposition only where one was published.** `disposition` is a
|
|
3959
3980
|
`DispositionCode.id` from the task's own `dispositions` capability, and `notes` obeys that
|
|
@@ -3961,14 +3982,10 @@ capability's `notes` setting. A task publishing no codes still receives `complet
|
|
|
3961
3982
|
|
|
3962
3983
|
### Where a command executes
|
|
3963
3984
|
|
|
3964
|
-
Every command reaches the provider through `execute`, with no branch at the call site
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
| Command | The provider's part |
|
|
3969
|
-
| --- | --- |
|
|
3970
|
-
| `mute` | **Record it, and keep the history.** The microphone is the host's, so Omni has already stopped the audio through `VoiceMediaSession.setMuted()` — no adapter can do that on the host's behalf. The command still arrives because the provider owns the task's record: it holds the current state for supervision, and each change as a `muted` handling step, exactly as it does for `held`. A platform that never hears about it shows a supervisor an agent who sounds absent for no reason, and reports a call with a silence it cannot explain. |
|
|
3971
|
-
| Every other command | **Perform it.** `hold`, `transfer`, `conference`, `end-call`, `recording` and the rest act on the platform's own call leg, its bridge, or its record of the task. Nothing has happened until the provider applies them. |
|
|
3985
|
+
Every command reaches the provider through `execute`, with no branch at the call site, and every
|
|
3986
|
+
command asks the provider to **perform** something: `hold`, `transfer`, `conference`, `end-call`,
|
|
3987
|
+
`recording` and the rest act on the platform's own call leg, its bridge, or its record of the
|
|
3988
|
+
task. Nothing has happened until the provider applies them, and `failed` means nothing happened.
|
|
3972
3989
|
|
|
3973
3990
|
**The provider performs every action; the host only offers it.** A control drawn on the host is
|
|
3974
3991
|
an affordance, never the enforcement: the host asks, and the provider does or declines. A provider
|
|
@@ -3977,16 +3994,75 @@ honouring its own declaration, not the host deciding policy -- a host that refus
|
|
|
3977
3994
|
its own reading of a queue's flag has decided something that was never its to decide. What a
|
|
3978
3995
|
command means on the platform is the provider's to work out: `end-call` ends the conversation, and
|
|
3979
3996
|
which legs on which bridge that touches is a fact about the switch, never a choice the host makes.
|
|
3980
|
-
The one
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3997
|
+
The one thing the host performs physically is the microphone, and that is not a command at all.
|
|
3998
|
+
|
|
3999
|
+
### The station is the host's
|
|
4000
|
+
|
|
4001
|
+
The microphone, the speaker, the headset and its buttons are the station's, and the station is the
|
|
4002
|
+
host's. So mute is not a capability a provider declares, not a control a queue allows or a team
|
|
4003
|
+
locks, not a preference the person keeps with the provider, and not a command: nothing about the
|
|
4004
|
+
station crosses to the provider except two things -- the station's condition, in the host report,
|
|
4005
|
+
and the record of when the agent could not be heard, through `recordStep`. A host offers Mute on
|
|
4006
|
+
every voice task with media open, under its own provisioning, in `in-progress` and `paused`
|
|
4007
|
+
alone, and performs it through `VoiceMediaSession.setMuted()`. A task carrying
|
|
4008
|
+
`capabilities.mute` is refused (`task.capability.unknown`), and so is a `mute` command
|
|
4009
|
+
(`command.type`), a `mute` preference (`preference.id`) and a `mute` policy (`team.policy.key`).
|
|
4010
|
+
|
|
4011
|
+
**Two mutes, and the host states which its Mute performs.** A *stream* mute stops the audio the
|
|
4012
|
+
host sends: the microphone keeps capturing, the party hears silence, nothing else on the machine
|
|
4013
|
+
changes, and every softphone host can do it. A *station* mute silences the microphone itself at
|
|
4014
|
+
the operating system: every application on the machine goes quiet, the system shows it, and a
|
|
4015
|
+
headset that follows the system follows it. Only a native host can do it. `Host.mute` says which
|
|
4016
|
+
this host does -- `stream` or `station` -- stated on a softphone login, where the host holds the
|
|
4017
|
+
microphone, and absent on a desk phone and off voice (`host.mute.required` / `.unexpected`). A
|
|
4018
|
+
browser says `stream`. A native host says whichever it does. Nothing on the wire says what kind of
|
|
4019
|
+
application the host is; a provider reads only what the host declared.
|
|
4020
|
+
|
|
4021
|
+
**A silenced device says who silenced it.** `audio.input.flowing: false` and
|
|
4022
|
+
`audio.output.flowing: false` carry `mutedBy`: `host` when the host's own Mute did it -- which is
|
|
4023
|
+
only ever true of a host whose mute is `station`, since a stream mute leaves the microphone
|
|
4024
|
+
flowing -- and `station` when a slider on the headset or the operating system did, which the host
|
|
4025
|
+
observed and did not do. Without the word, a station-muting host's own Mute would read as a fault
|
|
4026
|
+
and an adapter would take the agent out of ready in the middle of a call. A browser reads a station
|
|
4027
|
+
mute through the capture track's muted state, read-only, and cannot lift it; a native host reads
|
|
4028
|
+
the endpoint and can clear an operating-system mute. A hardware slider is nobody's to clear.
|
|
4029
|
+
|
|
4030
|
+
**The record carries the same word.** A `muted` handling leg is a period the agent could not be
|
|
4031
|
+
heard, and the record exists so that period is not a hole. The host reports every such period
|
|
4032
|
+
through `recordStep` -- its own Mute, and a station mute it observed during a call -- with
|
|
4033
|
+
`mutedBy` saying whose the silence was, and the provider writes the word into the entry
|
|
4034
|
+
(`task.handlingHistory.mutedBy`). A supervisor reading the record then sees "the agent muted for
|
|
4035
|
+
forty seconds" and "the agent's headset was muted for forty seconds" as the different things they
|
|
4036
|
+
are. See **The host records what it performs**.
|
|
4037
|
+
|
|
4038
|
+
**Mute has a lifecycle, and none of it is inferred.** A call that starts while the station is
|
|
4039
|
+
already muted begins a `muted` leg the moment its media starts, `mutedBy: "station"`. Media that
|
|
4040
|
+
ends while any leg is open ends the leg at that instant, as every host-performed leg ends. And the
|
|
4041
|
+
host's own Mute starts off on every call: an agent is never muted by the call before.
|
|
4042
|
+
|
|
4043
|
+
**Every press on a headset is the agent's own press**, performed the host's way, and the lights
|
|
4044
|
+
follow the host's state. The `personConsent` guarantee is honoured by a hook-switch press exactly
|
|
4045
|
+
as by a click. What a call-control headset sends, and what becomes of it:
|
|
4046
|
+
|
|
4047
|
+
| Input | Where it comes from | What the host does | What crosses to the provider |
|
|
4048
|
+
| --- | --- | --- | --- |
|
|
4049
|
+
| Mute button | The headset, on the HID Telephony usage page: a Phone Mute report on the press, a Mute LED the host writes back | A press of the host's Mute, performed the host's way; the LED follows the host's state, so button, light and control are one state with one owner | The `muted` leg, `mutedBy: "host"` |
|
|
4050
|
+
| Hook switch | The headset, HID Telephony | A press of the host's Answer or End call, only where the task is in a phase that has one; the off-hook and ring lights follow the task | The existing `answer` and `end-call` commands |
|
|
4051
|
+
| Flash, redial, speed dial | Older headsets and desk-phone style devices | Flash is Hold and Resume where the task offers `hold`; redial and speed dial map to nothing on a desk with no dial pad on a transfer, and are ignored | Nothing new |
|
|
4052
|
+
| Volume up and down | The headset's own amplifier, or the operating system through the consumer-control keys | Nothing: the device and the system handle it | Nothing, except that a speaker at nought is `audio.output.flowing: false` where the host can know it |
|
|
4053
|
+
| Microphone gain and level | The operating system, the headset's own boost | A level meter, so the agent can see they are heard | Nothing: a level is a flicker, not a state |
|
|
4054
|
+
| Hardware or operating-system mute | A slider on the headset, the system's input mute | Observes it, publishes `flowing: false, mutedBy: "station"`, records the leg during a call, and tells the agent which it is where it knows -- headset or system -- and what to do; a host whose mute is `station` clears a system mute itself, a browser can only say so | The report and the leg |
|
|
4055
|
+
| Device changed, unplugged, permission revoked | The operating system | Re-capture, prompt, report | Already `audio.input` and `audio.output` with `lost`, `denied`, `no-device` |
|
|
4056
|
+
|
|
4057
|
+
**What the host shows.** Beside Mute, a standing line while the station is muted, saying which and
|
|
4058
|
+
what to do, with a button that clears a system mute only on a host whose mute is `station`. The
|
|
4059
|
+
Mute control does not pretend: while the station is muted it shows the agent cannot be heard and
|
|
4060
|
+
that pressing it will not change that, and a press still toggles the host's own mute, so the two
|
|
4061
|
+
states stay separate and the agent is not unmuted by surprise when the slider moves back. Before a
|
|
4062
|
+
call, the same line in the ready state, so an agent does not learn of it from a silent customer;
|
|
4063
|
+
whether work is held back is the adapter's decision from the report, as it is for every station
|
|
4064
|
+
fact. Where a provider's own platform holds a mute of its own -- a bridge that silences a leg --
|
|
4065
|
+
that is a fact about the switch, reported as the provider sees fit, and never the host's Mute.
|
|
3990
4066
|
|
|
3991
4067
|
### Which commands need a capability
|
|
3992
4068
|
|
|
@@ -4009,6 +4085,17 @@ declared:
|
|
|
4009
4085
|
| `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`. |
|
|
4010
4086
|
| Everything else | Its own named capability. |
|
|
4011
4087
|
|
|
4088
|
+
**A control on the contact belongs to the handling phases**, `in-progress` and `paused`:
|
|
4089
|
+
`hold`, `resume` and `pause`, `end-call`, `recording`, every `transfer` and `conference` action, and
|
|
4090
|
+
every `lead-assist` action. Each acts on the call or the conversation, and only while there is one.
|
|
4091
|
+
Before `in-progress` nothing has been placed or opened; in `completing` the handling has ended -- a
|
|
4092
|
+
call with nobody on it, a conversation closed -- and a wrap-up that still shows Transfer shows it
|
|
4093
|
+
for nothing. The capability stays declared, because it is a property of the task and the task is
|
|
4094
|
+
still open; the phase says there is nothing to use it on. Omni shows none of these controls outside
|
|
4095
|
+
the two phases, and `validateTaskCommand` refuses each of them there (`command.phase.handling`).
|
|
4096
|
+
The commands with a phase of their own -- `answer`, `accept`, `decline` and `reject` in `pending`,
|
|
4097
|
+
`call` in `preview`, `connect-back` in `completing`, `complete` in any -- are not among them.
|
|
4098
|
+
|
|
4012
4099
|
`validateTaskCommand(command, task)` holds a command to this table at runtime, both ways: the
|
|
4013
4100
|
capability it needs, the phase it belongs to, and the state that has to stand. The task it wants
|
|
4014
4101
|
is the one the provider published, not a host's own mapping of it: a host that keeps only its
|
|
@@ -4376,8 +4463,9 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
4376
4463
|
| `validateHostGuarantees(guarantees)` | What a host promises: only the guarantees this contract names, each declared by presence and never `false`. The harness validates the guarantees of whatever host a test hands the adapter. |
|
|
4377
4464
|
| `validateHandlingReport(report, path?, manifest?)` | What the host reports of a leg it performed, for an adapter to check before forwarding: a task, a step, when it began, a positive `seconds` where stated, and an explicit `ended` that carries the final duration. Given the manifest, a running report is refused unless it declares `runningStepReports`. |
|
|
4378
4465
|
| `validateHostReport(report)` | The host's own report as published to an adapter: `online`, and where there is audio, an input that is `available` with the microphone and `flowing`, or `unavailable` with a reason and the failure that says why, and an output that is `available` or `unavailable` with its failure. The harness validates whatever host a test hands the adapter; `stillHost(report)` builds one that never changes. |
|
|
4466
|
+
| `validateHostMute(mute, softphone)` | What the host's Mute does, stated on a softphone login and nowhere else: `stream` or `station` (`host.mute`), required where the host holds a microphone (`host.mute.required`) and refused where it does not (`host.mute.unexpected`). The harness holds `ConnectContext.host.mute` to it. |
|
|
4379
4467
|
| `validateAuthenticationResult(result, method)` | What `start()` or `complete()` answered: a challenge or a rejection, a login or a rejection. A rejection's failure is held to its rules -- an `omni.` code the contract lists, and `omni.phone-not-permitted` never retryable, since the agent's station is configuration. `validateAuthenticationFailure(failure)` is the same check on a failure alone. |
|
|
4380
|
-
| `validateTaskCommand(command, task?)` | What a command needs to be issuable, against the task it names: its own shape -- a dial's `dialId`, a transfer's item, a remove naming exactly one person -- and, with the task, the capability the table above gates it on (`command.capability.<name>`, `.locked`), the phase it belongs to (`command.phase
|
|
4468
|
+
| `validateTaskCommand(command, task?)` | What a command needs to be issuable, against the task it names: its own shape -- a dial's `dialId`, a transfer's item, a remove naming exactly one person -- and, with the task, the capability the table above gates it on (`command.capability.<name>`, `.locked`), the phase it belongs to (`command.phase.*`, `command.phase.handling` for every control on the call or the conversation), and the state that has to stand: a consulted entry, a lead requested, somebody else still on the call (`command.conference.remove.alone`). A host validates before sending and an adapter before acting. |
|
|
4381
4469
|
| `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. |
|
|
4382
4470
|
| `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. Omni applies it to every state a session publishes — the republished as much as the first. |
|
|
4383
4471
|
|
|
@@ -4448,7 +4536,14 @@ complete it with a disposition where the agent completes -- and holds every step
|
|
|
4448
4536
|
host holds a provider to. Each command is validated against the task as published
|
|
4449
4537
|
(`drive.command.*`), each answer for its method, a refusal of a control the task offered is a
|
|
4450
4538
|
violation (`drive.command.failed`), and an event the provider owes and never sends is one too
|
|
4451
|
-
(`drive.timeout`, after `driveTimeoutMs`, 5000 by default).
|
|
4539
|
+
(`drive.timeout`, after `driveTimeoutMs`, 5000 by default). With the audio open on a softphone,
|
|
4540
|
+
the drive mutes it for one second and reports the leg through `recordStep`, begun and then ended,
|
|
4541
|
+
expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); and where the provider
|
|
4542
|
+
restates the task's record afterwards, the leg is in it or the hole is named
|
|
4543
|
+
(`drive.recordStep.history`). Once the call has ended and the task is
|
|
4544
|
+
`completing`, the drive sends `hold` once more, past the validator that would hold it back, and
|
|
4545
|
+
expects `failed`: the adapter is the second gate on a control on the contact, and one that applies
|
|
4546
|
+
it on a call that is over is named (`drive.command.handling`). A task the agent completes is
|
|
4452
4547
|
completed from wherever it stands once the drive has nothing left to do on it -- from `completing`
|
|
4453
4548
|
after an `end-call`, or from `in-progress` where there is no call to end, which is every chat and
|
|
4454
4549
|
email and a voice task offering no `endCall` -- so a conversation reaches its end as a call does.
|