@xema/omni-protocol 0.1.45 → 0.1.47
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 +45 -1
- package/dist/index.js +1 -0
- package/dist/testing.js +25 -8
- package/dist/validation.d.ts +9 -0
- package/dist/validation.js +63 -0
- package/guide.md +102 -8
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -36,9 +36,21 @@ export type DialId = string;
|
|
|
36
36
|
export type DurationSeconds = number;
|
|
37
37
|
/** New channels require a later protocol version. */
|
|
38
38
|
export type Channel = "voice" | "chat" | "email";
|
|
39
|
+
/**
|
|
40
|
+
* An IANA time zone name, such as `Asia/Kolkata` -- never an offset, which cannot survive a
|
|
41
|
+
* daylight-saving boundary, and a day boundary is exactly where that bites.
|
|
42
|
+
*/
|
|
43
|
+
export type TimeZone = string;
|
|
39
44
|
export interface User {
|
|
40
45
|
id: UserId;
|
|
41
46
|
displayName: string;
|
|
47
|
+
/**
|
|
48
|
+
* The zone this person's day is reckoned in, as the provider keeps it: stated by the host at
|
|
49
|
+
* authentication, stored on the agent, and carried on every identity and every described user
|
|
50
|
+
* so a day-scoped figure -- theirs or a colleague's a lead is reading -- is bucketed by the
|
|
51
|
+
* right day. Never absent: a person whose day nobody can name is not on this wire.
|
|
52
|
+
*/
|
|
53
|
+
timeZone: TimeZone;
|
|
42
54
|
}
|
|
43
55
|
/** Key/value detail on a `Contact` or a `ScheduledActivity`. A task's attributes are typed. */
|
|
44
56
|
export interface Attribute {
|
|
@@ -84,6 +96,14 @@ export interface DialCapability {
|
|
|
84
96
|
*/
|
|
85
97
|
export type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "rejected" | "cancelled" | "unexplained";
|
|
86
98
|
export declare const DIAL_OUTCOMES: readonly ["answered", "busy", "no-answer", "unreachable", "rejected", "cancelled", "unexplained"];
|
|
99
|
+
/**
|
|
100
|
+
* How the agent hears the call: on a `softphone`, where the host owns the audio and opens it
|
|
101
|
+
* through `openMedia`, or on a `deskPhone`, a handset the platform rings, where the host opens
|
|
102
|
+
* nothing and only shows the call. A voice manifest lists the phones its platform supports, and
|
|
103
|
+
* the host picks one for the login at authentication.
|
|
104
|
+
*/
|
|
105
|
+
export type Phone = "softphone" | "deskPhone";
|
|
106
|
+
export declare const PHONES: readonly ["softphone", "deskPhone"];
|
|
87
107
|
/** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
|
|
88
108
|
export declare const IDLE_CAPABILITIES: readonly ["dial", "personalBrowser", "calendar", "contacts"];
|
|
89
109
|
export type IdleCapability = (typeof IDLE_CAPABILITIES)[number];
|
|
@@ -135,6 +155,11 @@ export interface Manifest<C extends Channel = Channel> {
|
|
|
135
155
|
* a task that may transfer, conference, or call back -- and forbidden off voice, where nothing dials.
|
|
136
156
|
*/
|
|
137
157
|
dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
|
|
158
|
+
/**
|
|
159
|
+
* The phones this platform can put an agent on. Required of every voice manifest, and forbidden
|
|
160
|
+
* off voice, where there is no call to hear. The host picks one of these per login.
|
|
161
|
+
*/
|
|
162
|
+
phones?: C extends "voice" ? Phone[] : never;
|
|
138
163
|
/**
|
|
139
164
|
* The provider takes running reports of a host-performed step -- `recordStep` with `seconds`
|
|
140
165
|
* so far and no `ended`. Absent, the host sends exactly two reports per leg, when it began and
|
|
@@ -151,6 +176,17 @@ export interface AuthenticationContext {
|
|
|
151
176
|
protocolVersion: number;
|
|
152
177
|
/** Omni's identity for this login. The same value arrives later as `ConnectContext.loginId`. */
|
|
153
178
|
loginId: string;
|
|
179
|
+
/**
|
|
180
|
+
* The zone the agent's day is reckoned in, as the host's clock has it, stated before any
|
|
181
|
+
* identity exists so the first `authenticated` state already carries it. The same value arrives
|
|
182
|
+
* later as `ConnectContext.timeZone`.
|
|
183
|
+
*/
|
|
184
|
+
timeZone: TimeZone;
|
|
185
|
+
/**
|
|
186
|
+
* How this login hears its calls, chosen by the host from the manifest's `phones`. Required for
|
|
187
|
+
* a voice provider and absent for any other. The same value arrives later as `ConnectContext.phone`.
|
|
188
|
+
*/
|
|
189
|
+
phone?: Phone;
|
|
154
190
|
/** Scoped to this provider's manifest id. */
|
|
155
191
|
secrets: SecretStore;
|
|
156
192
|
signal?: AbortSignal;
|
|
@@ -366,6 +402,14 @@ export interface ConnectContext {
|
|
|
366
402
|
loginId: string;
|
|
367
403
|
/** Omni-side policy: whether the agent's tasks are accepted without asking them. */
|
|
368
404
|
autoAcceptTasks?: boolean;
|
|
405
|
+
/**
|
|
406
|
+
* The zone the agent's day is reckoned in, the same value passed as
|
|
407
|
+
* `AuthenticationContext.timeZone`. The provider stores it on the agent and carries it on the
|
|
408
|
+
* identity; a roaming agent corrects it by signing in.
|
|
409
|
+
*/
|
|
410
|
+
timeZone: TimeZone;
|
|
411
|
+
/** The same value passed as `AuthenticationContext.phone`: how this login hears its calls. */
|
|
412
|
+
phone?: Phone;
|
|
369
413
|
/**
|
|
370
414
|
* The host's report of the agent's station, to consult before declaring the agent ready to the
|
|
371
415
|
* platform and whenever it changes. Omni reports; the adapter decides.
|
|
@@ -1367,7 +1411,7 @@ export interface Connection<C extends Channel = Channel> {
|
|
|
1367
1411
|
executeTeamMonitor?(request: TeamMonitorCommandRequest): Promise<TeamCommandResult>;
|
|
1368
1412
|
/** Required when the login declares `capabilities.team.policyControl`. */
|
|
1369
1413
|
executeTeamPolicy?(request: TeamPolicyCommandRequest): Promise<TeamCommandResult>;
|
|
1370
|
-
/** Required of
|
|
1414
|
+
/** Required of a voice adapter whose manifest lists `softphone`: on one, the call's audio lands in Omni. */
|
|
1371
1415
|
openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
|
|
1372
1416
|
/** Required when the login declares `capabilities.preferences`: the person's own choice, kept by the provider and republished as `authenticated`. */
|
|
1373
1417
|
setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@ export function negotiateProtocolVersion(adapterVersions, hostVersions = OMNI_SU
|
|
|
16
16
|
return shared.length === 0 ? undefined : Math.max(...shared);
|
|
17
17
|
}
|
|
18
18
|
export const DIAL_OUTCOMES = ["answered", "busy", "no-answer", "unreachable", "rejected", "cancelled", "unexplained"];
|
|
19
|
+
export const PHONES = ["softphone", "deskPhone"];
|
|
19
20
|
/** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
|
|
20
21
|
export const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"];
|
|
21
22
|
/** What Omni calls each idle capability. */
|
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, } from "./validation.js";
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateManifest, validateResult, validateSnapshot, validateTimeZone, validatePhone, isTimeZone, } 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
|
|
@@ -255,14 +255,21 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
255
255
|
// voice connection's host reports its audio and no other does, and the host the adapter
|
|
256
256
|
// receives is wrapped so the harness can tell whether the adapter ever asked.
|
|
257
257
|
violations.push(...validateHostGuarantees(context.host.guarantees, "context.host.guarantees"));
|
|
258
|
+
// The zone is the host's to state, so a context without one is a host that cannot exist.
|
|
259
|
+
violations.push(...validateTimeZone(context.timeZone, "context.timeZone"));
|
|
260
|
+
// How the agent hears the call decides what the host owns: on a softphone the host has the
|
|
261
|
+
// audio and reports it; on a desk phone, or off voice, there is none for it to report.
|
|
262
|
+
violations.push(...validatePhone(context.phone, adapter.manifest, "context.phone"));
|
|
263
|
+
const softphone = adapter.manifest.channel === "voice" && context.phone === "softphone";
|
|
258
264
|
const first = context.host.report();
|
|
259
265
|
violations.push(...validateHostReport(first, "context.host"));
|
|
260
266
|
const hasAudio = isRecord(first) && first.audio !== undefined;
|
|
261
|
-
if (
|
|
262
|
-
violations.push({ rule: "context.host.audio.required", path: "context.host.audio", message: "a
|
|
267
|
+
if (softphone && !hasAudio) {
|
|
268
|
+
violations.push({ rule: "context.host.audio.required", path: "context.host.audio", message: "a softphone login's host reports its audio" });
|
|
263
269
|
}
|
|
264
|
-
if (
|
|
265
|
-
violations.push({ rule: "context.host.audio.unexpected", path: "context.host.audio",
|
|
270
|
+
if (!softphone && hasAudio) {
|
|
271
|
+
violations.push({ rule: "context.host.audio.unexpected", path: "context.host.audio",
|
|
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` });
|
|
266
273
|
}
|
|
267
274
|
unsubscribeHost = context.host.subscribe(report => {
|
|
268
275
|
violations.push(...validateHostReport(report, "context.host"));
|
|
@@ -279,9 +286,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
279
286
|
// than an `enabled` flag, so its presence is the declaration.
|
|
280
287
|
if (adapter.manifest.idleCapabilities?.dial !== undefined)
|
|
281
288
|
requireMethod(live, "dial", "the manifest declares dial");
|
|
282
|
-
//
|
|
283
|
-
if (
|
|
284
|
-
requireMethod(live, "openMedia", "the
|
|
289
|
+
// 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
|
+
if (softphone)
|
|
291
|
+
requireMethod(live, "openMedia", "the login is on a softphone");
|
|
285
292
|
const eventIds = new Set();
|
|
286
293
|
unsubscribe = connection.subscribe(envelope => {
|
|
287
294
|
observeEvent(envelope, seen);
|
|
@@ -332,6 +339,16 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
332
339
|
const capacity = await connection.setCapacity({ count: 1 });
|
|
333
340
|
const malformed = validateResult(capacity, "setCapacity", "connection.setCapacity");
|
|
334
341
|
violations.push(...malformed);
|
|
342
|
+
// The provider republishes the agent's day: once connected, the identity carries the zone the
|
|
343
|
+
// host sent. That proves the round trip, not the store -- an echo passes it -- and the name
|
|
344
|
+
// says only what it tests; storage is proved by a colleague's zone arriving from describeUsers().
|
|
345
|
+
if (isTimeZone(context.timeZone) && current().identity.timeZone !== context.timeZone) {
|
|
346
|
+
violations.push({
|
|
347
|
+
rule: "authentication.identity.timeZone.republished",
|
|
348
|
+
path: "authentication.identity.timeZone",
|
|
349
|
+
message: `the host sent ${context.timeZone} at connect and the identity says ${String(current().identity.timeZone)}: the provider republishes the agent's time zone on the identity`,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
335
352
|
// A refusal is read only from a result that has the shape of one.
|
|
336
353
|
if (malformed.length === 0 && capacity.status === "failed") {
|
|
337
354
|
violations.push({
|
package/dist/validation.d.ts
CHANGED
|
@@ -9,6 +9,11 @@ export declare function assertNoViolations(violations: readonly ProtocolViolatio
|
|
|
9
9
|
/** Every field is optional, so this checks what is present rather than what is missing. */
|
|
10
10
|
export declare function validateContact(contact: unknown, path?: string): ProtocolViolation[];
|
|
11
11
|
export declare function validateScheduledActivity(activity: unknown, path?: string): ProtocolViolation[];
|
|
12
|
+
/**
|
|
13
|
+
* The host's choice of phone for a login, held to the manifest: one the platform listed, required
|
|
14
|
+
* on voice, absent elsewhere.
|
|
15
|
+
*/
|
|
16
|
+
export declare function validatePhone(value: unknown, manifest: unknown, path?: string): ProtocolViolation[];
|
|
12
17
|
export declare function validateManifest(manifest: unknown, path?: string): ProtocolViolation[];
|
|
13
18
|
export interface TaskValidationContext {
|
|
14
19
|
/** The provider's channel, from its manifest. A task must agree with it. */
|
|
@@ -67,6 +72,10 @@ export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" |
|
|
|
67
72
|
* failure, a success carrying one, or an `omni.` code the contract lacks are each refused.
|
|
68
73
|
*/
|
|
69
74
|
export declare function validateResult(result: unknown, method: ResultMethod, path?: string, dialId?: string): ProtocolViolation[];
|
|
75
|
+
/** An IANA zone name the runtime knows: `Asia/Kolkata` is one, an offset or a made-up name is not. */
|
|
76
|
+
export declare function isTimeZone(value: unknown): value is string;
|
|
77
|
+
/** The zone a host sends at connect: required, and an IANA name. */
|
|
78
|
+
export declare function validateTimeZone(value: unknown, path?: string): ProtocolViolation[];
|
|
70
79
|
/** What a login is validated against beyond its own shape. */
|
|
71
80
|
export interface LoginValidationContext {
|
|
72
81
|
/** The level ids in force, from the manifest. The defaults when absent. */
|
package/dist/validation.js
CHANGED
|
@@ -84,6 +84,7 @@ const OFFERABLE_PHASES = membersOf({
|
|
|
84
84
|
pending: true, confirmed: true, preview: true,
|
|
85
85
|
});
|
|
86
86
|
const PREVIEW_DEADLINES = membersOf({ calls: true, expires: true });
|
|
87
|
+
const PHONES = membersOf({ softphone: true, deskPhone: true });
|
|
87
88
|
const TEAM_CAPABILITIES = membersOf({ breakControl: true, leadAssistControl: true, policyControl: true, monitorControl: true });
|
|
88
89
|
const MONITOR_MODES = membersOf({ monitor: true, whisper: true, barge: true });
|
|
89
90
|
const COMPLETED_BY = membersOf({ agent: true, provider: true });
|
|
@@ -307,6 +308,45 @@ function validateDialOutcomes(manifest, path, into) {
|
|
|
307
308
|
into.require(declared.some((outcome) => EXPLAINED_FAILURES.includes(outcome)), "manifest.dialOutcomes.unexplained.alone", path, "unexplained says the switch gave no cause, which is a claim only a provider that reports causes can make: declare at least one of busy, no-answer, unreachable, rejected, cancelled beside it");
|
|
308
309
|
}
|
|
309
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* A voice platform says which phones it can put an agent on -- a softphone in the host, a desk
|
|
313
|
+
* phone it rings -- and nothing off voice does, since there is no call to hear.
|
|
314
|
+
*/
|
|
315
|
+
function validatePhones(manifest, path, into) {
|
|
316
|
+
const declared = manifest.phones;
|
|
317
|
+
if (manifest.channel !== "voice") {
|
|
318
|
+
into.require(declared === undefined, "manifest.phones.channel", path, `a ${String(manifest.channel)} provider has no call to hear and declares no phones`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (!Array.isArray(declared) || declared.length === 0) {
|
|
322
|
+
into.add("manifest.phones.required", path, "a voice manifest lists the phones its platform can put an agent on: softphone, deskPhone, or both");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
declared.forEach((phone, index) => {
|
|
326
|
+
if (into.oneOf(phone, PHONES, "manifest.phone", `${path}[${index}]`) && declared.indexOf(phone) !== index) {
|
|
327
|
+
into.add("manifest.phone.unique", `${path}[${index}]`, `duplicate phone: ${String(phone)}`);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* The host's choice of phone for a login, held to the manifest: one the platform listed, required
|
|
333
|
+
* on voice, absent elsewhere.
|
|
334
|
+
*/
|
|
335
|
+
export function validatePhone(value, manifest, path = "context.phone") {
|
|
336
|
+
const into = new Collector();
|
|
337
|
+
const channel = isPlainObject(manifest) ? manifest.channel : undefined;
|
|
338
|
+
const phones = isPlainObject(manifest) && Array.isArray(manifest.phones) ? manifest.phones : [];
|
|
339
|
+
if (channel !== "voice") {
|
|
340
|
+
into.require(value === undefined, "context.phone.unexpected", path, `a ${String(channel)} login has no call to hear and chooses no phone`);
|
|
341
|
+
return into.violations;
|
|
342
|
+
}
|
|
343
|
+
if (!into.require(value !== undefined, "context.phone.required", path, "a voice login says how the agent hears the call: one of the manifest's phones"))
|
|
344
|
+
return into.violations;
|
|
345
|
+
if (into.oneOf(value, PHONES, "context.phone", path)) {
|
|
346
|
+
into.require(phones.includes(value), "context.phone.unsupported", path, `the host chose ${String(value)} and the manifest lists ${phones.length === 0 ? "no phones" : phones.join(", ")}`);
|
|
347
|
+
}
|
|
348
|
+
return into.violations;
|
|
349
|
+
}
|
|
310
350
|
/** Whether a manifest says how a dial ends, and so may publish tasks that dial. `undefined` where there is no manifest to ask. */
|
|
311
351
|
function manifestDials(manifest) {
|
|
312
352
|
return isPlainObject(manifest) ? Array.isArray(manifest.dialOutcomes) : undefined;
|
|
@@ -347,6 +387,7 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
347
387
|
if (channelValid)
|
|
348
388
|
validateIdleCapabilities(manifest.idleCapabilities, manifest.channel, `${path}.idleCapabilities`, into);
|
|
349
389
|
validateDialOutcomes(manifest, `${path}.dialOutcomes`, into);
|
|
390
|
+
validatePhones(manifest, `${path}.phones`, into);
|
|
350
391
|
if (manifest.phaseLabels !== undefined) {
|
|
351
392
|
if (!isPlainObject(manifest.phaseLabels)) {
|
|
352
393
|
into.add("manifest.phaseLabels.shape", `${path}.phaseLabels`, "phaseLabels must be an object when present");
|
|
@@ -1733,6 +1774,26 @@ export function validateResult(result, method, path = "result", dialId) {
|
|
|
1733
1774
|
// ---------------------------------------------------------------------------
|
|
1734
1775
|
// Authentication.
|
|
1735
1776
|
// ---------------------------------------------------------------------------
|
|
1777
|
+
/** An IANA zone name the runtime knows: `Asia/Kolkata` is one, an offset or a made-up name is not. */
|
|
1778
|
+
export function isTimeZone(value) {
|
|
1779
|
+
// The runtime accepts a bare offset such as +05:30 as a zone; this wire does not, since an
|
|
1780
|
+
// offset cannot say what day it is on either side of a daylight-saving change.
|
|
1781
|
+
if (typeof value !== "string" || value.trim() === "" || /^[+-]/.test(value))
|
|
1782
|
+
return false;
|
|
1783
|
+
try {
|
|
1784
|
+
new Intl.DateTimeFormat("en", { timeZone: value });
|
|
1785
|
+
return true;
|
|
1786
|
+
}
|
|
1787
|
+
catch {
|
|
1788
|
+
return false;
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
/** The zone a host sends at connect: required, and an IANA name. */
|
|
1792
|
+
export function validateTimeZone(value, path = "context.timeZone") {
|
|
1793
|
+
const into = new Collector();
|
|
1794
|
+
into.require(isTimeZone(value), "context.timeZone", path, "a host states the agent's time zone at connect as an IANA name, such as Asia/Kolkata; an offset cannot survive a daylight-saving boundary");
|
|
1795
|
+
return into.violations;
|
|
1796
|
+
}
|
|
1736
1797
|
function validateUser(value, rule, path, into) {
|
|
1737
1798
|
if (!isPlainObject(value)) {
|
|
1738
1799
|
into.add(rule, path, "an identity must be an object");
|
|
@@ -1740,6 +1801,8 @@ function validateUser(value, rule, path, into) {
|
|
|
1740
1801
|
}
|
|
1741
1802
|
into.require(isUserId(value.id), `${rule}.id`, `${path}.id`, "an identity needs a provider-issued user id");
|
|
1742
1803
|
into.filled(value.displayName, `${rule}.displayName`, `${path}.displayName`, "an identity needs a display name");
|
|
1804
|
+
// Never absent: the host stated the zone at authentication, before this identity existed.
|
|
1805
|
+
into.require(isTimeZone(value.timeZone), `${rule}.timeZone`, `${path}.timeZone`, "every identity carries the agent's time zone as an IANA name, such as Asia/Kolkata; the host stated it at authentication");
|
|
1743
1806
|
}
|
|
1744
1807
|
function validateUserCapabilitiesInto(value, path, into, levels) {
|
|
1745
1808
|
if (!isPlainObject(value)) {
|
package/guide.md
CHANGED
|
@@ -135,9 +135,12 @@ adapter**, **Live connection**, and **Authenticating with a provider**.
|
|
|
135
135
|
```ts
|
|
136
136
|
type Channel = "voice" | "chat" | "email";
|
|
137
137
|
|
|
138
|
+
type TimeZone = string;
|
|
139
|
+
|
|
138
140
|
type User = {
|
|
139
141
|
id: UserId;
|
|
140
142
|
displayName: string;
|
|
143
|
+
timeZone: TimeZone;
|
|
141
144
|
};
|
|
142
145
|
|
|
143
146
|
type Attribute = { key: string; value: string };
|
|
@@ -168,6 +171,8 @@ type DialCapability = { destinations: DialDestinations };
|
|
|
168
171
|
|
|
169
172
|
type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "rejected" | "cancelled" | "unexplained";
|
|
170
173
|
|
|
174
|
+
type Phone = "softphone" | "deskPhone";
|
|
175
|
+
|
|
171
176
|
type IdleCapabilities<C extends Channel = Channel> = {
|
|
172
177
|
personalBrowser?: PersonalBrowserCapability;
|
|
173
178
|
calendar?: true;
|
|
@@ -185,6 +190,7 @@ type Manifest<C extends Channel = Channel> = {
|
|
|
185
190
|
taskTypePresentation?: Record<string, TaskTypePresentation>;
|
|
186
191
|
orgLevels?: LevelDeclaration[];
|
|
187
192
|
dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
|
|
193
|
+
phones?: C extends "voice" ? Phone[] : never;
|
|
188
194
|
runningStepReports?: true;
|
|
189
195
|
};
|
|
190
196
|
```
|
|
@@ -225,6 +231,8 @@ type SecretStore = {
|
|
|
225
231
|
type AuthenticationContext = {
|
|
226
232
|
protocolVersion: number;
|
|
227
233
|
loginId: string;
|
|
234
|
+
timeZone: TimeZone;
|
|
235
|
+
phone?: Phone;
|
|
228
236
|
secrets: SecretStore;
|
|
229
237
|
signal?: AbortSignal;
|
|
230
238
|
log?: (entry: unknown) => void;
|
|
@@ -296,6 +304,8 @@ type ConnectContext = {
|
|
|
296
304
|
protocolVersion: number;
|
|
297
305
|
loginId: string;
|
|
298
306
|
autoAcceptTasks?: boolean;
|
|
307
|
+
timeZone: TimeZone;
|
|
308
|
+
phone?: Phone;
|
|
299
309
|
host: Host;
|
|
300
310
|
signal?: AbortSignal;
|
|
301
311
|
log?: (entry: unknown) => void;
|
|
@@ -1445,6 +1455,7 @@ compile time.
|
|
|
1445
1455
|
| `phaseLabels` | Optional static adapter-defined display names for canonical `TaskPhase` values. They cannot vary at runtime. |
|
|
1446
1456
|
| `taskTypePresentation` | Optional static adapter-defined presentation keyed by exact `taskType`. It names the item and its optional agent-facing reference. |
|
|
1447
1457
|
| `orgLevels` | The organisation's whole ladder as the provider calls it, each level with the label a desk shows for "who decided". Stated outright, `person` included: what it leaves out does not exist. Omitted for the typical four, `DEFAULT_LEVELS`. See **Who decides what an agent may do**. |
|
|
1458
|
+
| `phones` | Voice only, and required there: the phones this platform can put an agent on, `softphone` (the call's audio lands in the host) and/or `deskPhone` (a handset the platform rings; the host shows the call and opens nothing). The host picks one per login. See **How the agent hears the call**. |
|
|
1448
1459
|
| `dialOutcomes` | Voice only. How a dial can end on this platform, as it distinguishes them: `answered` and at least one way of not reaching the destination. Required of a provider that dials at all — an idle dialpad, or tasks that transfer, conference or call back — and a `dial-outcome` carries only a declared member. See **Every dial has an outcome**. |
|
|
1449
1460
|
| `runningStepReports` | The provider takes running reports of a host-performed step — `recordStep` with `seconds` so far and no `ended`. Omitted, the host sends exactly two reports per leg, when it began and when it ended, and a running one is refused. See **The host records what it performs**. |
|
|
1450
1461
|
|
|
@@ -1700,6 +1711,8 @@ Creates the provider-scoped authentication session.
|
|
|
1700
1711
|
| --- | --- |
|
|
1701
1712
|
| `protocolVersion` | The negotiated version, fixed for this login. |
|
|
1702
1713
|
| `loginId` | Omni-generated identity for this login. The same value Omni later passes as `ConnectContext.loginId`, and how an adapter ties a connection back to the session that authenticated it. |
|
|
1714
|
+
| `timeZone` | The zone the agent's day is reckoned in, as an IANA name from the host's clock, stated before any identity exists so the first `authenticated` state already carries it. The same value Omni later passes as `ConnectContext.timeZone`. See **The agent's day**. |
|
|
1715
|
+
| `phone` | How this login hears its calls, chosen by the host from the manifest's `phones`: required for a voice provider, absent for any other. The same value Omni later passes as `ConnectContext.phone`. See **How the agent hears the call**. |
|
|
1703
1716
|
| `secrets` | Omni-provided `SecretStore`, scoped to this provider's manifest id. |
|
|
1704
1717
|
| `signal` | Optional cancellation signal. |
|
|
1705
1718
|
| `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
|
|
@@ -1847,7 +1860,7 @@ too, by the rule that a command asking for a state answers success when that sta
|
|
|
1847
1860
|
failure:
|
|
1848
1861
|
|
|
1849
1862
|
```ts
|
|
1850
|
-
{ status: "authenticated", identity: { id: "1042", displayName: "Asha Rao" }, capabilities: { breaks: true } }
|
|
1863
|
+
{ status: "authenticated", identity: { id: "1042", displayName: "Asha Rao", timeZone: "Asia/Kolkata" }, capabilities: { breaks: true } }
|
|
1851
1864
|
```
|
|
1852
1865
|
|
|
1853
1866
|
The `User` it carries is the **root of this provider's user namespace**. Every other person this
|
|
@@ -1898,10 +1911,40 @@ Creates one live provider connection for the signed-in agent.
|
|
|
1898
1911
|
| `protocolVersion` | Version negotiated before authentication. Fixed for this login. |
|
|
1899
1912
|
| `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. |
|
|
1900
1913
|
| `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()`. |
|
|
1914
|
+
| `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**. |
|
|
1915
|
+
| `phone` | The same value passed as `AuthenticationContext.phone`: how this login hears its calls. |
|
|
1901
1916
|
| `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**. |
|
|
1902
1917
|
| `signal` | Optional cancellation signal. Stop startup promptly when aborted and do not begin new work. |
|
|
1903
1918
|
| `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
|
|
1904
1919
|
|
|
1920
|
+
### The agent's day
|
|
1921
|
+
|
|
1922
|
+
Every instant on this wire carries an explicit offset, so a moment is unambiguous everywhere and
|
|
1923
|
+
a desk renders it in the viewer's clock without help. A **day** is different: hours toward
|
|
1924
|
+
target, an answer streak, a per-queue count for today -- anything bucketed by day -- is bucketed
|
|
1925
|
+
by somebody's day, and a platform that was never told whose uses its own. An agent in Chennai
|
|
1926
|
+
then finds their day rolling at 05:30, and a night shift in Chicago lands in two buckets.
|
|
1927
|
+
|
|
1928
|
+
So the host says whose day it is, and it says so first. `AuthenticationContext.timeZone` is the
|
|
1929
|
+
agent's zone as an IANA name -- `Asia/Kolkata`, `America/Chicago` -- never an offset, since an
|
|
1930
|
+
offset cannot survive a daylight-saving boundary and a day boundary is exactly where that bites
|
|
1931
|
+
(`context.timeZone`). It is stated before any identity exists, and `ConnectContext.timeZone` is the
|
|
1932
|
+
same value again. The provider **stores it on the agent** and carries it as `identity.timeZone` on
|
|
1933
|
+
every `authenticated` state and on every `User` it returns from `describeUsers()`, so a lead
|
|
1934
|
+
reading a colleague's yesterday sees the colleague's yesterday and a summary is bucketed by the
|
|
1935
|
+
right day after a session has ended. A roaming agent corrects it by signing in from where they are.
|
|
1936
|
+
|
|
1937
|
+
**The zone is never absent.** Time zone awareness is a first-party property of this wire, not a
|
|
1938
|
+
field to fill in later: an identity without one is refused (`authentication.identity.timeZone`), and
|
|
1939
|
+
so is a zone that is not an IANA name, wherever it appears. Nothing is ever assumed in its place --
|
|
1940
|
+
not the viewer's browser, which gives a different answer per reader for the same record, and not the
|
|
1941
|
+
provider's clock. The harness holds a provider to the round trip: the identity carries the zone the
|
|
1942
|
+
host stated (`authentication.identity.timeZone.republished`). **The round trip is not the store.**
|
|
1943
|
+
An adapter that echoes the stated zone back onto the identity passes that check with nothing kept,
|
|
1944
|
+
and a lead reading a colleague's day would still get the wrong one. What proves the store is a zone
|
|
1945
|
+
the run never sent: a colleague's `User` from `describeUsers()` carrying theirs. A provider's own
|
|
1946
|
+
tests are where that is shown, with a second agent whose zone arrived through another session.
|
|
1947
|
+
|
|
1905
1948
|
### Who the agent is
|
|
1906
1949
|
|
|
1907
1950
|
`ConnectContext` names no agent. The adapter already knows who is connected: it authenticated them,
|
|
@@ -1943,7 +1986,7 @@ surface in one place, and what obliges an adapter to implement each one.
|
|
|
1943
1986
|
| `disconnect()` | Always. |
|
|
1944
1987
|
| `setCapacity(capacity)` | Always. Nothing may be allocated until a capacity is stated, so there is no connection that does not receive it. |
|
|
1945
1988
|
| `execute(request)` | Always. Every channel has commands no capability gates — see **Which commands need a capability**. |
|
|
1946
|
-
| `describeUsers(ids)` | The adapter publishes any `UserId`: on `ImposedBreak.by`, a roster, or `handlingHistory[].by`. |
|
|
1989
|
+
| `describeUsers(ids)` | The adapter publishes any `UserId`: on `ImposedBreak.by`, a roster, or `handlingHistory[].by`. Each `User` carries its `timeZone` where the provider knows it. |
|
|
1947
1990
|
| `dial(request)` | The manifest declares `idleCapabilities.dial`, and with it `dialOutcomes`. |
|
|
1948
1991
|
| `requestBreak(request)` | The login declares `capabilities.breaks`. |
|
|
1949
1992
|
| `commitBreak()` | The login declares `capabilities.breaks`. Commit and cancel are not optional halves of it. |
|
|
@@ -1955,7 +1998,7 @@ surface in one place, and what obliges an adapter to implement each one.
|
|
|
1955
1998
|
| `setPreference(request)` | The login declares `capabilities.preferences`: the person's choice has to have somewhere to go. |
|
|
1956
1999
|
| `recordStep(report)` | A task declares `mute`: the host performs that leg, and the provider's record has to have somewhere to take it. See **The host records what it performs**. |
|
|
1957
2000
|
| `executeTeamPolicy(command)` | The login declares `capabilities.team.policyControl`. |
|
|
1958
|
-
| `openMedia(request)` | The manifest
|
|
2001
|
+
| `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. |
|
|
1959
2002
|
|
|
1960
2003
|
**The four break methods stand or fall together.** Declaring `capabilities.breaks` at login and then
|
|
1961
2004
|
implementing `requestBreak` without `commitBreak` leaves an agent granted a break that can never
|
|
@@ -2104,7 +2147,8 @@ medium the task arrives on, not a separate decision.
|
|
|
2104
2147
|
|
|
2105
2148
|
**Once a task is accepted, the call that comes with it is answered.** Omni has no discretion
|
|
2106
2149
|
there and the provider is not consulted twice: one decision about the work, and the medium
|
|
2107
|
-
follows it. Where the audio lands
|
|
2150
|
+
follows it. Where the audio lands was settled at sign-in — in Omni on a softphone, on the handset
|
|
2151
|
+
on a desk phone — and is not in question per call. See **How the agent hears the call**.
|
|
2108
2152
|
|
|
2109
2153
|
Automatic acceptance still begins with `task-offered`.
|
|
2110
2154
|
|
|
@@ -2249,6 +2293,12 @@ ends with an `expired` outcome naming `preview`. The two travel together
|
|
|
2249
2293
|
"expires in 12s" from the provider's word. With neither, the agent has as long as they need.
|
|
2250
2294
|
Reaching the instant is not itself a transition: the provider reports what it did, as an event.
|
|
2251
2295
|
|
|
2296
|
+
**Drop both fields when the phase moves.** A provider that builds the `in-progress` task by
|
|
2297
|
+
spreading the `preview` one carries `previewEndsAt` and `atDeadline` with it, and the host refuses
|
|
2298
|
+
the update (`task.preview.deadline.unexpected`) while the provider's own state looks right: the
|
|
2299
|
+
desk hears nothing and the provider sees nothing wrong. The task past preview has no deadline to
|
|
2300
|
+
wait for, so it carries neither field.
|
|
2301
|
+
|
|
2252
2302
|
**A task is never its audio.** A voice task is the allocation: the call is offered when it is
|
|
2253
2303
|
routed to the agent and accepted as its `acceptance` dictates, and its presence and phase follow
|
|
2254
2304
|
the provider's reports about the work — never the audio. Wherever audio moves — an offer, a hold, a
|
|
@@ -3628,8 +3678,9 @@ is the one that knows whether an agent without a microphone, or without a speake
|
|
|
3628
3678
|
The host's own obligations here — asking at connect, never publishing `not-asked` when it does,
|
|
3629
3679
|
publishing a state and not a flicker — are Omni's tests' to hold. `exerciseAdapter` holds the
|
|
3630
3680
|
other side: it validates the shape of whatever host a test hands the adapter, requires `audio` on
|
|
3631
|
-
a
|
|
3632
|
-
refuses a voice adapter that never asked the
|
|
3681
|
+
a softphone login and none elsewhere -- not on a desk phone, not off voice
|
|
3682
|
+
(`context.host.audio.required` / `.unexpected`) -- and refuses a voice adapter that never asked the
|
|
3683
|
+
host anything (`connection.host.consulted`).
|
|
3633
3684
|
|
|
3634
3685
|
### Capacity around setup
|
|
3635
3686
|
|
|
@@ -3661,10 +3712,53 @@ once; a break says they are not working. Collapsing the two would leave a provid
|
|
|
3661
3712
|
an agent at their limit from an agent who has gone to lunch, and only one of those needs a reason,
|
|
3662
3713
|
a decision and a return.
|
|
3663
3714
|
|
|
3715
|
+
### How the agent hears the call
|
|
3716
|
+
|
|
3717
|
+
A voice platform can put an agent on a **softphone**, where the call's audio lands in the host
|
|
3718
|
+
and Omni owns the microphone and the playback, or on a **desk phone**, a handset on the platform's
|
|
3719
|
+
switch that the platform rings, where the host owns no audio and only shows the call. Which of
|
|
3720
|
+
these a platform can do is a fact about the platform, and which one a login is on is a choice the
|
|
3721
|
+
host makes for that agent -- so the manifest declares and the host chooses.
|
|
3722
|
+
|
|
3723
|
+
```ts
|
|
3724
|
+
// Manifest: what the platform can do
|
|
3725
|
+
phones: ["softphone", "deskPhone"]
|
|
3726
|
+
// The host, at authentication and again at connect: which one this login is on
|
|
3727
|
+
{ phone: "softphone" }
|
|
3728
|
+
```
|
|
3729
|
+
|
|
3730
|
+
A voice manifest lists its `phones` and any other channel lists none (`manifest.phones.required`,
|
|
3731
|
+
`manifest.phones.channel`). The host's `phone` is required on a voice login, absent on any other,
|
|
3732
|
+
and one the manifest listed (`context.phone.required`, `.unexpected`, `.unsupported`). Everything
|
|
3733
|
+
about audio then follows the phone rather than the channel: on a softphone the host reports its
|
|
3734
|
+
audio, the adapter implements `openMedia`, and `task-media-started` is the word to open it; on a
|
|
3735
|
+
desk phone the host reports no audio, opens nothing, and `task-media-started` still marks the
|
|
3736
|
+
moment the call is live so the desk shows it, with the sound on the handset. A platform that lists
|
|
3737
|
+
`deskPhone` alone never implements `openMedia`; one that lists `softphone` always does.
|
|
3738
|
+
|
|
3739
|
+
**The mode comes from the host; the status comes from the provider.** The host knows which kind
|
|
3740
|
+
of station the agent signed in at, because the person chose it, and nothing else can know that.
|
|
3741
|
+
The provider knows whether that station can carry a call right now -- whether a handset is
|
|
3742
|
+
registered -- which a host in a browser cannot see. So the host's `phone` selects the branch, and
|
|
3743
|
+
the provider's own knowledge of the device decides readiness within it. A provider never overrides
|
|
3744
|
+
the host's declaration from its device record: treating a desk-phone login as a softphone because
|
|
3745
|
+
the handset is momentarily unregistered, and demanding a microphone of it, is the reading this
|
|
3746
|
+
sentence exists to refuse. What the provider does when the handset is not registered is what it
|
|
3747
|
+
does for any station that cannot take a call -- hold the agent not-ready and say why.
|
|
3748
|
+
|
|
3749
|
+
**On a desk-phone login the host never calls `openMedia`.** There is no stream to hand over and
|
|
3750
|
+
no audio to attach; a host that calls it anyway is in error, and an adapter that receives the call
|
|
3751
|
+
answers `unavailable` with a non-retryable failure, since waiting changes nothing about a station
|
|
3752
|
+
that is a telephone. The harness requires no `openMedia` of such an adapter and never calls it.
|
|
3753
|
+
|
|
3754
|
+
**Which handset a desk-phone login rings is the platform's configuration for that agent**, and
|
|
3755
|
+
this wire never asks the agent for it: a host declares `phone` and nothing more. What a platform
|
|
3756
|
+
asks on surfaces of its own is its own decision.
|
|
3757
|
+
|
|
3664
3758
|
### Opening the audio
|
|
3665
3759
|
|
|
3666
|
-
`openMedia` hands Omni the remote audio for one task. Every
|
|
3667
|
-
|
|
3760
|
+
`openMedia` hands Omni the remote audio for one task. Every adapter whose manifest lists
|
|
3761
|
+
`softphone` implements it, because on a softphone the call's audio lands in Omni:
|
|
3668
3762
|
|
|
3669
3763
|
```ts
|
|
3670
3764
|
openMedia({ taskId, localAudio }): Promise<OpenMediaResult>
|