@xema/omni-protocol 0.1.60 → 0.1.62
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 +34 -4
- package/dist/testing.d.ts +13 -2
- package/dist/testing.js +60 -19
- package/dist/validation.d.ts +2 -0
- package/dist/validation.js +17 -1
- package/guide.md +64 -11
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -172,6 +172,17 @@ export interface SecretStore {
|
|
|
172
172
|
set(key: string, value: string): Promise<void>;
|
|
173
173
|
delete(key: string): Promise<void>;
|
|
174
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* A small store for one login's operational state -- what an adapter holds about open work that
|
|
177
|
+
* its platform cannot hold for it, such as the handling legs a host reported. Kept by the host for
|
|
178
|
+
* the life of the login, across a reload of the host, and cleared at sign-out. Never for anything
|
|
179
|
+
* sensitive: that is `SecretStore`, whose contract is that a host may clear it aggressively.
|
|
180
|
+
*/
|
|
181
|
+
export interface LoginStore {
|
|
182
|
+
get(key: string): Promise<string | undefined>;
|
|
183
|
+
set(key: string, value: string): Promise<void>;
|
|
184
|
+
delete(key: string): Promise<void>;
|
|
185
|
+
}
|
|
175
186
|
export interface AuthenticationContext {
|
|
176
187
|
protocolVersion: number;
|
|
177
188
|
/** Omni's identity for this login. The same value arrives later as `ConnectContext.loginId`. */
|
|
@@ -427,7 +438,7 @@ export interface Host {
|
|
|
427
438
|
report(): HostReport;
|
|
428
439
|
subscribe(listener: (report: HostReport) => void): Unsubscribe;
|
|
429
440
|
}
|
|
430
|
-
export
|
|
441
|
+
export type ConnectContext = {
|
|
431
442
|
protocolVersion: number;
|
|
432
443
|
/** The session that authenticated this connection. */
|
|
433
444
|
loginId: string;
|
|
@@ -439,16 +450,35 @@ export interface ConnectContext {
|
|
|
439
450
|
* identity; a roaming agent corrects it by signing in.
|
|
440
451
|
*/
|
|
441
452
|
timeZone: TimeZone;
|
|
442
|
-
/**
|
|
453
|
+
/** Where the adapter keeps this login's operational state across a reload of the host. */
|
|
454
|
+
store: LoginStore;
|
|
455
|
+
/** The same value passed as `AuthenticationContext.phone`: how this login hears its calls. Narrowed by the arms below. */
|
|
443
456
|
phone?: Phone;
|
|
444
457
|
/**
|
|
445
458
|
* The host's report of the agent's station, to consult before declaring the agent ready to the
|
|
446
|
-
* platform and whenever it changes. Omni reports; the adapter decides.
|
|
459
|
+
* platform and whenever it changes. Omni reports; the adapter decides. What it states about its
|
|
460
|
+
* Mute is tied to the phone by the arms below.
|
|
447
461
|
*/
|
|
448
462
|
host: Host;
|
|
449
463
|
signal?: AbortSignal;
|
|
450
464
|
log?: (entry: unknown) => void;
|
|
451
|
-
}
|
|
465
|
+
} & (
|
|
466
|
+
/**
|
|
467
|
+
* On a softphone the host holds the microphone and states what its Mute does; on a desk phone,
|
|
468
|
+
* or off voice, it holds none and states nothing. The arms make the omission a compile error
|
|
469
|
+
* where a live seat would otherwise be the first to find it.
|
|
470
|
+
*/
|
|
471
|
+
{
|
|
472
|
+
phone: "softphone";
|
|
473
|
+
host: {
|
|
474
|
+
mute: HostMute;
|
|
475
|
+
};
|
|
476
|
+
} | {
|
|
477
|
+
phone?: "deskPhone";
|
|
478
|
+
host: {
|
|
479
|
+
mute?: never;
|
|
480
|
+
};
|
|
481
|
+
});
|
|
452
482
|
export type TransportStatus = "connecting" | "active" | "error";
|
|
453
483
|
/**
|
|
454
484
|
* What revives a connection that reported `error`: `reconnect` -- the login is good, dispose this
|
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 HostMute, 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 LoginStore, 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
|
/**
|
|
@@ -126,6 +126,10 @@ export declare class TaskStream {
|
|
|
126
126
|
/** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
|
|
127
127
|
dialled(dialId: string): void;
|
|
128
128
|
private noteDials;
|
|
129
|
+
/** The entries of a task's record, each by step and instant, or undefined where the task carries no record. */
|
|
130
|
+
private static record;
|
|
131
|
+
/** What a restated record lost of the one read before it: nothing, or the entries by step and instant. */
|
|
132
|
+
private static lost;
|
|
129
133
|
private static stated;
|
|
130
134
|
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
131
135
|
seed(snapshot: unknown): void;
|
|
@@ -153,11 +157,18 @@ export declare function assertBreakFollowsItsRequests(envelopes: readonly Provid
|
|
|
153
157
|
* follows the media ending is `completing` or `task-ended`.
|
|
154
158
|
*/
|
|
155
159
|
export declare function assertMediaFollowsTheTask(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
|
|
160
|
+
/** A login store that lives in memory for the test: what most adapter tests hand `exerciseAdapter` as `context.store`. */
|
|
161
|
+
export declare function memoryStore(): LoginStore;
|
|
156
162
|
/**
|
|
157
163
|
* A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`.
|
|
158
164
|
* A softphone host states what its Mute does; a desk-phone or conversation host has no microphone and states nothing.
|
|
159
165
|
*/
|
|
160
|
-
export declare function stillHost(report
|
|
166
|
+
export declare function stillHost(report: HostReport, guarantees: HostGuarantees, mute: HostMute): Host & {
|
|
167
|
+
mute: HostMute;
|
|
168
|
+
};
|
|
169
|
+
export declare function stillHost(report?: HostReport, guarantees?: HostGuarantees): Host & {
|
|
170
|
+
mute?: never;
|
|
171
|
+
};
|
|
161
172
|
/** One provider as the host sees it when freezing the providers a break attempt asks. */
|
|
162
173
|
export interface BreakCandidate {
|
|
163
174
|
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, validateHostMute, validateHandlingReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateHostMute, validateLoginStore, validateHandlingReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
4
4
|
/**
|
|
5
5
|
* A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
|
|
@@ -273,12 +273,16 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
273
273
|
}
|
|
274
274
|
// What the host's Mute does is stated where the host holds a microphone, and nowhere else.
|
|
275
275
|
violations.push(...validateHostMute(context.host.mute, softphone, "context.host.mute"));
|
|
276
|
+
// The login's store is the host's to provide: an adapter that composes a record keeps in it what its platform cannot.
|
|
277
|
+
violations.push(...validateLoginStore(context.store, "context.store"));
|
|
276
278
|
unsubscribeHost = context.host.subscribe(report => {
|
|
277
279
|
violations.push(...validateHostReport(report, "context.host"));
|
|
278
280
|
});
|
|
279
281
|
let consulted = false;
|
|
282
|
+
// The wrapped host carries the mute kind the test's host stated, so the arms of ConnectContext still hold.
|
|
280
283
|
const host = {
|
|
281
284
|
guarantees: context.host.guarantees,
|
|
285
|
+
...(context.host.mute === undefined ? {} : { mute: context.host.mute }),
|
|
282
286
|
report: () => { consulted = true; return context.host.report(); },
|
|
283
287
|
subscribe: listener => { consulted = true; return context.host.subscribe(listener); },
|
|
284
288
|
};
|
|
@@ -737,6 +741,20 @@ export class TaskStream {
|
|
|
737
741
|
this.dialled(entry.dialId);
|
|
738
742
|
}
|
|
739
743
|
}
|
|
744
|
+
/** The entries of a task's record, each by step and instant, or undefined where the task carries no record. */
|
|
745
|
+
static record(task) {
|
|
746
|
+
if (!isRecord(task) || !isRecord(task.handlingHistory) || !Array.isArray(task.handlingHistory.steps))
|
|
747
|
+
return undefined;
|
|
748
|
+
return new Set(task.handlingHistory.steps.filter(isRecord).map(entry => `${String(entry.step)}@${String(entry.at)}`));
|
|
749
|
+
}
|
|
750
|
+
/** What a restated record lost of the one read before it: nothing, or the entries by step and instant. */
|
|
751
|
+
static lost(was, now) {
|
|
752
|
+
if (was === undefined || was.size === 0)
|
|
753
|
+
return [];
|
|
754
|
+
if (now === undefined)
|
|
755
|
+
return [...was];
|
|
756
|
+
return [...was].filter(key => !now.has(key));
|
|
757
|
+
}
|
|
740
758
|
static stated(task) {
|
|
741
759
|
const media = isRecord(task) && (task.media === "started" || task.media === "ended") ? task.media : "none";
|
|
742
760
|
// The stage of every dialled entry the room names by its dial, so an update can be held to the
|
|
@@ -748,7 +766,7 @@ export class TaskStream {
|
|
|
748
766
|
stages.set(entry.dialId, entry.stage);
|
|
749
767
|
}
|
|
750
768
|
}
|
|
751
|
-
return { phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages };
|
|
769
|
+
return { phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task) };
|
|
752
770
|
}
|
|
753
771
|
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
754
772
|
seed(snapshot) {
|
|
@@ -782,6 +800,11 @@ export class TaskStream {
|
|
|
782
800
|
if (was !== undefined && (was.source === "queue" || was.source === "ungoverned") && task.capabilitySource === "undetermined") {
|
|
783
801
|
refuse("stream.snapshot.capabilitySource", `${at}.snapshot.tasks[${index}].capabilitySource`, `${task.id} was published under ${was.source} terms and the snapshot says undetermined: terms once read stay read`);
|
|
784
802
|
}
|
|
803
|
+
// A record once read is not unread: a resync restates it whole, or with more, never with less.
|
|
804
|
+
const lost = TaskStream.lost(was?.record, TaskStream.record(task));
|
|
805
|
+
if (lost.length > 0) {
|
|
806
|
+
refuse("stream.snapshot.handlingHistory", `${at}.snapshot.tasks[${index}].handlingHistory`, `${task.id}'s record lost ${lost.join(", ")} on the snapshot: an entry read by the host stays in the record until the task ends`);
|
|
807
|
+
}
|
|
785
808
|
});
|
|
786
809
|
}
|
|
787
810
|
this.seed(event.snapshot);
|
|
@@ -807,6 +830,13 @@ export class TaskStream {
|
|
|
807
830
|
if ((known.source === "queue" || known.source === "ungoverned") && isRecord(event.task) && event.task.capabilitySource === "undetermined") {
|
|
808
831
|
refuse("stream.taskUpdated.capabilitySource", `${at}.task.capabilitySource`, `${id} was published under ${known.source} terms and now says undetermined: terms once read stay read, and a re-read that fails is a diagnostic, not a republish`);
|
|
809
832
|
}
|
|
833
|
+
// A record once read is not unread: an update restates it whole, or with more, never with less.
|
|
834
|
+
{
|
|
835
|
+
const lost = TaskStream.lost(known.record, TaskStream.record(event.task));
|
|
836
|
+
if (lost.length > 0) {
|
|
837
|
+
refuse("stream.taskUpdated.handlingHistory", `${at}.task.handlingHistory`, `${id}'s record lost ${lost.join(", ")} on the update: an entry read by the host stays in the record until the task ends`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
810
840
|
if (known.media === "ended") {
|
|
811
841
|
const phase = isRecord(event.task) ? String(event.task.phase) : "";
|
|
812
842
|
if (phase !== "completing") {
|
|
@@ -1000,7 +1030,25 @@ async function driveOneCall(drive) {
|
|
|
1000
1030
|
if (await updated(t => t.phase === "in-progress" || t.phase === "completing", "the task leaving preview after Call") === undefined)
|
|
1001
1031
|
return found;
|
|
1002
1032
|
}
|
|
1033
|
+
// The other direction of step 4, wherever the task stands outside the handling phases with the
|
|
1034
|
+
// control still declared: a host holds it back (command.phase.handling), and an adapter that
|
|
1035
|
+
// receives it anyway must refuse it -- so the drive sends it past the validator and expects failed.
|
|
1036
|
+
const holdRefusedOutsideHandling = async () => {
|
|
1037
|
+
const phase = String(latestTask().phase);
|
|
1038
|
+
let answer;
|
|
1039
|
+
try {
|
|
1040
|
+
answer = await drive.connection.execute({ taskId, command: { type: "hold" } });
|
|
1041
|
+
}
|
|
1042
|
+
catch (error) {
|
|
1043
|
+
refuse("drive.command.rejected", "drive.command.hold", `execute rejected rather than answered: ${String(error)}`);
|
|
1044
|
+
}
|
|
1045
|
+
if (isRecord(answer) && answer.status !== "failed") {
|
|
1046
|
+
refuse("drive.command.handling", "drive.command.hold", `the provider applied hold on a ${phase} task: a control on the contact belongs to in-progress or paused, and the adapter is the second gate`);
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1003
1049
|
if (latestTask().phase === "confirmed") {
|
|
1050
|
+
if (offers("hold"))
|
|
1051
|
+
await holdRefusedOutsideHandling();
|
|
1004
1052
|
if (await updated(t => t.phase !== "confirmed", "the task leaving confirmed") === undefined)
|
|
1005
1053
|
return found;
|
|
1006
1054
|
}
|
|
@@ -1086,19 +1134,7 @@ async function driveOneCall(drive) {
|
|
|
1086
1134
|
if (mediaEnded !== undefined)
|
|
1087
1135
|
cursor = mediaEnded.at;
|
|
1088
1136
|
if (await updated(t => t.phase === "completing", "the task completing after its media ended") !== undefined && offers("hold")) {
|
|
1089
|
-
|
|
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
|
-
}
|
|
1137
|
+
await holdRefusedOutsideHandling();
|
|
1102
1138
|
}
|
|
1103
1139
|
}
|
|
1104
1140
|
}
|
|
@@ -1219,10 +1255,15 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
|
|
|
1219
1255
|
envelopes.forEach((envelope, index) => found.push(...stream.apply(envelope, `envelopes[${index}]`)));
|
|
1220
1256
|
assertNoViolations(found, "The media follows the task");
|
|
1221
1257
|
}
|
|
1222
|
-
/**
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1258
|
+
/** A login store that lives in memory for the test: what most adapter tests hand `exerciseAdapter` as `context.store`. */
|
|
1259
|
+
export function memoryStore() {
|
|
1260
|
+
const kept = new Map();
|
|
1261
|
+
return {
|
|
1262
|
+
get: async (key) => kept.get(key),
|
|
1263
|
+
set: async (key, value) => { kept.set(key, value); },
|
|
1264
|
+
delete: async (key) => { kept.delete(key); },
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1226
1267
|
export function stillHost(report = { online: true }, guarantees = {}, mute) {
|
|
1227
1268
|
return { guarantees, ...(mute === undefined ? {} : { mute }), report: () => report, subscribe: () => () => undefined };
|
|
1228
1269
|
}
|
package/dist/validation.d.ts
CHANGED
|
@@ -56,6 +56,8 @@ export declare function validateEventEnvelope(envelope: unknown, manifest: unkno
|
|
|
56
56
|
* phone the microphone is the phone's, and off voice there is none. Neither the value nor its
|
|
57
57
|
* absence is inferred from what kind of application the host is.
|
|
58
58
|
*/
|
|
59
|
+
/** The login's store, which the host provides on every connection: three functions, and nothing else is a store. */
|
|
60
|
+
export declare function validateLoginStore(store: unknown, path?: string): ProtocolViolation[];
|
|
59
61
|
export declare function validateHostMute(mute: unknown, softphone: boolean, path?: string): ProtocolViolation[];
|
|
60
62
|
/**
|
|
61
63
|
* What a host promises. Presence is the guarantee, so a key declared `false` is refused: a
|
package/dist/validation.js
CHANGED
|
@@ -752,8 +752,12 @@ function validateHandlingHistory(value, path, into) {
|
|
|
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
754
|
// A muted entry carries whose the silence was, as the host reported it; no other step has it.
|
|
755
|
-
|
|
755
|
+
// And it names the agent: the host has exactly one, the provider knows who, so an unattributed
|
|
756
|
+
// muted leg is a record that dropped a fact it held rather than one it could not establish.
|
|
757
|
+
if (entry.step === "muted") {
|
|
756
758
|
into.oneOf(entry.mutedBy, MUTED_BY, "task.handlingHistory.mutedBy", `${at}.mutedBy`);
|
|
759
|
+
into.require(entry.by !== undefined, "task.handlingHistory.muted.by", `${at}.by`, "a muted leg is attributed to the login's agent: the host reported it, and the provider knows who the host's agent is");
|
|
760
|
+
}
|
|
757
761
|
else if (HANDLING_STEPS.includes(entry.step)) {
|
|
758
762
|
into.require(entry.mutedBy === undefined, "task.handlingHistory.mutedBy.unexpected", `${at}.mutedBy`, "only a muted step says who silenced the microphone");
|
|
759
763
|
}
|
|
@@ -1639,6 +1643,18 @@ const HOST_MUTES = membersOf({ stream: true, station: true });
|
|
|
1639
1643
|
* phone the microphone is the phone's, and off voice there is none. Neither the value nor its
|
|
1640
1644
|
* absence is inferred from what kind of application the host is.
|
|
1641
1645
|
*/
|
|
1646
|
+
/** The login's store, which the host provides on every connection: three functions, and nothing else is a store. */
|
|
1647
|
+
export function validateLoginStore(store, path = "store") {
|
|
1648
|
+
const into = new Collector();
|
|
1649
|
+
if (!isPlainObject(store)) {
|
|
1650
|
+
into.add("store.shape", path, "a connection carries the login's store: an object with get, set and delete");
|
|
1651
|
+
return into.violations;
|
|
1652
|
+
}
|
|
1653
|
+
for (const method of ["get", "set", "delete"]) {
|
|
1654
|
+
into.require(typeof store[method] === "function", `store.${method}`, `${path}.${method}`, `a login store ${method}s by key`);
|
|
1655
|
+
}
|
|
1656
|
+
return into.violations;
|
|
1657
|
+
}
|
|
1642
1658
|
export function validateHostMute(mute, softphone, path = "host.mute") {
|
|
1643
1659
|
const into = new Collector();
|
|
1644
1660
|
if (mute === undefined) {
|
package/guide.md
CHANGED
|
@@ -305,16 +305,26 @@ type Host = {
|
|
|
305
305
|
subscribe(listener: (report: HostReport) => void): Unsubscribe;
|
|
306
306
|
};
|
|
307
307
|
|
|
308
|
+
type LoginStore = {
|
|
309
|
+
get(key: string): Promise<string | undefined>;
|
|
310
|
+
set(key: string, value: string): Promise<void>;
|
|
311
|
+
delete(key: string): Promise<void>;
|
|
312
|
+
};
|
|
313
|
+
|
|
308
314
|
type ConnectContext = {
|
|
309
315
|
protocolVersion: number;
|
|
310
316
|
loginId: string;
|
|
311
317
|
autoAcceptTasks?: boolean;
|
|
312
318
|
timeZone: TimeZone;
|
|
319
|
+
store: LoginStore;
|
|
313
320
|
phone?: Phone;
|
|
314
321
|
host: Host;
|
|
315
322
|
signal?: AbortSignal;
|
|
316
323
|
log?: (entry: unknown) => void;
|
|
317
|
-
}
|
|
324
|
+
} & (
|
|
325
|
+
| { phone: "softphone"; host: { mute: HostMute } }
|
|
326
|
+
| { phone?: "deskPhone"; host: { mute?: never } }
|
|
327
|
+
);
|
|
318
328
|
|
|
319
329
|
type TransportStatus = "connecting" | "active" | "error";
|
|
320
330
|
|
|
@@ -1388,6 +1398,16 @@ replaces its local provider view; it does not overwrite the provider.
|
|
|
1388
1398
|
Every member of a closed set — break kinds, handling steps, destination kinds — must have a
|
|
1389
1399
|
normative definition; matching names alone do not establish shared meaning.
|
|
1390
1400
|
|
|
1401
|
+
### 10. Order on the wire is display order
|
|
1402
|
+
|
|
1403
|
+
A list the provider publishes — a destination directory, disposition codes, a task's custom
|
|
1404
|
+
controls, its browsers — is in the order the provider wants it shown, and a host keeps that order.
|
|
1405
|
+
A reader will find meaning in position unless something says there is none, so the provider
|
|
1406
|
+
decides: where it has an intention, an administrator's sequence, it lists in that order; where it
|
|
1407
|
+
has none, it orders by the label, so the order is visibly arbitrary and does not move under the
|
|
1408
|
+
reader. A directory that reorders itself as colleagues become free is a list lying about what it
|
|
1409
|
+
means. A record's steps are the exception with a stated order of their own, oldest first.
|
|
1410
|
+
|
|
1391
1411
|
## Provider adapter requirements
|
|
1392
1412
|
|
|
1393
1413
|
### 1. One adapter is one provider
|
|
@@ -1925,7 +1945,8 @@ Creates one live provider connection for the signed-in agent.
|
|
|
1925
1945
|
| `loginId` | Omni-generated identity for this login. It is the same value passed as `AuthenticationContext.loginId`, so an adapter can correlate this connection with the session that authenticated it. Stable across transport reconnects and changed only by a new login. |
|
|
1926
1946
|
| `autoAcceptTasks` | Agent provisioning policy relayed to the provider at login. Treated as `true` when omitted. When `true`, a pending task states its `acceptance`; when `false`, every task requires agent acceptance. Fixed for this connection, like everything else here: the provider states or omits `acceptance` by the value it was sent, and Omni validates by that same value, not by a policy that has since moved — a change reaches the provider through a fresh `connect()`. |
|
|
1927
1947
|
| `timeZone` | The same value passed as `AuthenticationContext.timeZone`. The provider stores it on the agent and carries it on the identity. See **The agent's day**. |
|
|
1928
|
-
| `phone` | The same value passed as `AuthenticationContext.phone`: how this login hears its calls. |
|
|
1948
|
+
| `phone` | The same value passed as `AuthenticationContext.phone`: how this login hears its calls. The type ties `host.mute` to it: a `softphone` login's host states what its Mute does, and a desk-phone or conversation login's host cannot, so the omission is a compile error rather than a live seat's discovery. See **The station is the host's**. |
|
|
1949
|
+
| `store` | The login's operational store, kept by the host for the life of the login, across a reload of the host, and cleared at sign-out: where an adapter that composes a record keeps what its platform cannot hold for it, such as the handling legs a host reported. Three functions, by key. Never for anything sensitive, which is `AuthenticationContext.secrets`, a store a host may clear aggressively. The harness requires it of every connection (`store.shape`, `store.get`, `.set`, `.delete`). |
|
|
1929
1950
|
| `host` | The host's report of the agent's station — devices, permissions, network — to consult before declaring the agent ready to the platform, and on every change. See **The host reports, the adapter decides**. |
|
|
1930
1951
|
| `signal` | Optional cancellation signal. Stop startup promptly when aborted and do not begin new work. |
|
|
1931
1952
|
| `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
|
|
@@ -2465,8 +2486,30 @@ Steps are `queued`, `offered`, `answered`, `held`, `muted`, `transferred`, `conf
|
|
|
2465
2486
|
**The record is one entry per occurrence, oldest first.** Each hold is its own `held` entry — `at`
|
|
2466
2487
|
when it began, `seconds` once it ended and omitted while it runs — and a second hold is a second
|
|
2467
2488
|
entry after the first, never a revision of it. The same goes for every step: two mutes are two
|
|
2468
|
-
`muted` entries
|
|
2469
|
-
|
|
2489
|
+
`muted` entries, and a call that joined two queues -- a menu's, then this one -- has two `queued`
|
|
2490
|
+
entries, one per join. Order is enforced: an entry earlier than the one before it is refused
|
|
2491
|
+
(`task.handlingHistory.order`). **Intervals between steps are the host's to subtract**, never a
|
|
2492
|
+
total the provider adds: *time to offer*, from the call's arrival to the agent's screen lighting
|
|
2493
|
+
up, is the `offered` entry's `at` minus the last `queued` entry's, and the ring is outside it.
|
|
2494
|
+
`queueSeconds` keeps the industry's meaning -- the whole wait until somebody answered -- and is not
|
|
2495
|
+
that interval.
|
|
2496
|
+
|
|
2497
|
+
**A record once read is not unread.** Every restatement of a task's record -- on `task-updated`,
|
|
2498
|
+
on a resync snapshot -- carries every entry the host has already read, by step and instant, and
|
|
2499
|
+
may add to them; it never has fewer, and never drops the record while the task is open. A fact
|
|
2500
|
+
stated to the host and then withdrawn without an event is a record contradicting itself, exactly
|
|
2501
|
+
as a task that lost the terms it had read would be, and the stream refuses it the same way
|
|
2502
|
+
(`stream.taskUpdated.handlingHistory`, `stream.snapshot.handlingHistory`). A provider whose
|
|
2503
|
+
platform cannot hold a leg the host reported keeps it in the login's `store` for the life of the
|
|
2504
|
+
task, so a reload of the host restates the same record; the entry is keyed by `step` and `at`, so
|
|
2505
|
+
a running hold restated with its final `seconds` is the same entry.
|
|
2506
|
+
|
|
2507
|
+
**The record is ordered by the instants stated in it, whoever stamped them.** The host stamps the
|
|
2508
|
+
legs it performs from the same clock it reports everything else with, and the provider writes a
|
|
2509
|
+
host leg into the record with the host's `at`, in its place among the others by that instant, and
|
|
2510
|
+
answers `recorded`: it never refuses a leg, or the task, for a timestamp it did not write. A host
|
|
2511
|
+
whose clock runs ahead of the platform's puts its own mute before the platform's answer, and the
|
|
2512
|
+
record shows what was stated; that is the host's clock to fix, not the provider's record to edit. **Handle time is anchored, not restarted.** It runs from the
|
|
2470
2513
|
`answered` step's `at` — from the task's first `in-progress` where the provider reports no
|
|
2471
2514
|
history — until the task's media ends, and a hold neither pauses nor resets it: the hold's own
|
|
2472
2515
|
duration is the `held` entry's `seconds`, and a desk that restarts its counter on resume is
|
|
@@ -2743,7 +2786,8 @@ republish as any other permission that changed while the task was open. The move
|
|
|
2743
2786
|
Terms once read stay read: a re-read that fails mid-task is not a new fact about the task, so the
|
|
2744
2787
|
last statement stands and the failure is a `diagnostic`, and a task that was published under
|
|
2745
2788
|
`queue` or `ungoverned` never returns to `undetermined`, on an update (`stream.taskUpdated.capabilitySource`)
|
|
2746
|
-
or on a resync snapshot (`stream.snapshot.capabilitySource`)
|
|
2789
|
+
or on a resync snapshot (`stream.snapshot.capabilitySource`); a record once read never loses an
|
|
2790
|
+
entry the same two ways (`stream.taskUpdated.handlingHistory`, `stream.snapshot.handlingHistory`).
|
|
2747
2791
|
|
|
2748
2792
|
What the agent is told differs by source, and only one source tells them anything. Under `queue`
|
|
2749
2793
|
and `ungoverned` the agent sees controls and nothing about where they came from: both are facts,
|
|
@@ -4031,14 +4075,19 @@ the endpoint and can clear an operating-system mute. A hardware slider is nobody
|
|
|
4031
4075
|
heard, and the record exists so that period is not a hole. The host reports every such period
|
|
4032
4076
|
through `recordStep` -- its own Mute, and a station mute it observed during a call -- with
|
|
4033
4077
|
`mutedBy` saying whose the silence was, and the provider writes the word into the entry
|
|
4034
|
-
(`task.handlingHistory.mutedBy`).
|
|
4078
|
+
(`task.handlingHistory.mutedBy`). The report names no agent because the host has exactly one, and
|
|
4079
|
+
the provider knows who that is: it attributes the leg to the login's agent in `by`, a fact it
|
|
4080
|
+
holds and not an inference, for a station mute as much as for the host's own. An unattributed
|
|
4081
|
+
`muted` entry is refused (`task.handlingHistory.muted.by`); a shared handset's unattributed hold
|
|
4082
|
+
is a different claim, and stands. A supervisor reading the record then sees "the agent muted for
|
|
4035
4083
|
forty seconds" and "the agent's headset was muted for forty seconds" as the different things they
|
|
4036
4084
|
are. See **The host records what it performs**.
|
|
4037
4085
|
|
|
4038
4086
|
**Mute has a lifecycle, and none of it is inferred.** A call that starts while the station is
|
|
4039
4087
|
already muted begins a `muted` leg the moment its media starts, `mutedBy: "station"`. Media that
|
|
4040
4088
|
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
|
|
4089
|
+
host's own Mute starts off on every call, which is every `task-media-started` -- a connect-back
|
|
4090
|
+
opens a second call on the same task with no offer -- so an agent is never muted by the call before.
|
|
4042
4091
|
|
|
4043
4092
|
**Every press on a headset is the agent's own press**, performed the host's way, and the lights
|
|
4044
4093
|
follow the host's state. The `personConsent` guarantee is honoured by a hook-switch press exactly
|
|
@@ -4464,6 +4513,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
4464
4513
|
| `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`. |
|
|
4465
4514
|
| `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
4515
|
| `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. |
|
|
4516
|
+
| `validateLoginStore(store)` | The login's store the host hands every connection: an object with `get`, `set` and `delete` (`store.shape`, `store.get`, `.set`, `.delete`). The harness holds `ConnectContext.store` to it. |
|
|
4467
4517
|
| `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. |
|
|
4468
4518
|
| `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. |
|
|
4469
4519
|
| `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. |
|
|
@@ -4540,10 +4590,13 @@ violation (`drive.command.failed`), and an event the provider owes and never sen
|
|
|
4540
4590
|
the drive mutes it for one second and reports the leg through `recordStep`, begun and then ended,
|
|
4541
4591
|
expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); and where the provider
|
|
4542
4592
|
restates the task's record afterwards, the leg is in it or the hole is named
|
|
4543
|
-
(`drive.recordStep.history`).
|
|
4544
|
-
`
|
|
4545
|
-
|
|
4546
|
-
|
|
4593
|
+
(`drive.recordStep.history`). Outside the handling phases with `hold` still declared -- in
|
|
4594
|
+
`confirmed`, where the provider publishes it, and in `completing` once the call has ended -- the
|
|
4595
|
+
drive sends `hold` past the validator that would hold it back, and expects `failed`: the adapter
|
|
4596
|
+
is the second gate on a control on the contact, and one that applies it where there is nothing to
|
|
4597
|
+
hold is named (`drive.command.handling`). The drive cannot put a task into a phase the provider
|
|
4598
|
+
never publishes, so a provider that goes straight from `pending` to `in-progress` is checked in
|
|
4599
|
+
`completing` alone. A task the agent completes is
|
|
4547
4600
|
completed from wherever it stands once the drive has nothing left to do on it -- from `completing`
|
|
4548
4601
|
after an `end-call`, or from `in-progress` where there is no call to end, which is every chat and
|
|
4549
4602
|
email and a voice task offering no `endCall` -- so a conversation reaches its end as a call does.
|