@xema/omni-protocol 0.1.27 → 0.1.29
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/README.md +1 -0
- package/dist/index.d.ts +34 -10
- package/dist/testing.d.ts +3 -3
- package/dist/testing.js +8 -3
- package/dist/validation.d.ts +6 -4
- package/dist/validation.js +51 -27
- package/guide.md +69 -42
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ system.
|
|
|
15
15
|
| `src/index.ts` | The TypeScript declarations. |
|
|
16
16
|
| `src/validation.ts` | Runtime validators Omni applies to adapter output. |
|
|
17
17
|
| `src/testing.ts` | Conformance helpers an adapter runs against its own test state. |
|
|
18
|
+
| `tests/` | One test file per source module, plus the guards over the guide and the repository's own text. |
|
|
18
19
|
|
|
19
20
|
## Entry points
|
|
20
21
|
|
package/dist/index.d.ts
CHANGED
|
@@ -62,7 +62,7 @@ export interface BrowserAccess {
|
|
|
62
62
|
}
|
|
63
63
|
export interface PersonalBrowserCapability {
|
|
64
64
|
access: BrowserAccess;
|
|
65
|
-
|
|
65
|
+
accessAppliesTo?: "initial-url" | "all-navigation";
|
|
66
66
|
}
|
|
67
67
|
export type DialDestinations = "contacts-only" | "any-number";
|
|
68
68
|
export interface DialCapability {
|
|
@@ -237,7 +237,7 @@ export type CompleteAuthenticationResult = {
|
|
|
237
237
|
failure: AuthenticationFailure;
|
|
238
238
|
};
|
|
239
239
|
export type AuthenticationActionResult = {
|
|
240
|
-
status: "
|
|
240
|
+
status: "applied";
|
|
241
241
|
} | {
|
|
242
242
|
status: "failed";
|
|
243
243
|
failure: AuthenticationFailure;
|
|
@@ -262,7 +262,7 @@ export interface AuthenticationSession {
|
|
|
262
262
|
export type HostAudioInput =
|
|
263
263
|
/** Omni has the microphone. `flowing` is false while the hardware or OS says no audio moves through it. */
|
|
264
264
|
{
|
|
265
|
-
status: "
|
|
265
|
+
status: "available";
|
|
266
266
|
localAudio: MediaStream;
|
|
267
267
|
flowing: boolean;
|
|
268
268
|
}
|
|
@@ -281,7 +281,7 @@ export type HostAudioUnavailableReason = "no-device" | "denied" | "not-asked" |
|
|
|
281
281
|
/** Why the host has no speaker: no device, or one that was removed. */
|
|
282
282
|
export type HostOutputUnavailableReason = "no-device" | "lost";
|
|
283
283
|
export type HostAudioOutput = {
|
|
284
|
-
status: "
|
|
284
|
+
status: "available";
|
|
285
285
|
} | {
|
|
286
286
|
status: "unavailable";
|
|
287
287
|
reason: HostOutputUnavailableReason;
|
|
@@ -303,7 +303,20 @@ export interface HostReport {
|
|
|
303
303
|
};
|
|
304
304
|
}
|
|
305
305
|
/** The host, as an adapter may ask it: a report now, and every change for the life of the connection. */
|
|
306
|
+
/**
|
|
307
|
+
* What the host promises the provider, declared once per connection. Presence is the guarantee:
|
|
308
|
+
* an absent key is a host that makes no such promise, and a provider that needs one checks before
|
|
309
|
+
* it acts -- tokenising a URL it would otherwise send in the clear, or declining to offer work
|
|
310
|
+
* that only a person may accept.
|
|
311
|
+
*/
|
|
312
|
+
export interface HostGuarantees {
|
|
313
|
+
/** Every task browser's `urlVisibility` is honoured in this host's chrome, tab by tab. */
|
|
314
|
+
browserUrlVisibility?: true;
|
|
315
|
+
/** A `consent` offer is accepted only by the person's own explicit act, never on their behalf. */
|
|
316
|
+
personConsent?: true;
|
|
317
|
+
}
|
|
306
318
|
export interface Host {
|
|
319
|
+
guarantees: HostGuarantees;
|
|
307
320
|
report(): HostReport;
|
|
308
321
|
subscribe(listener: (report: HostReport) => void): Unsubscribe;
|
|
309
322
|
}
|
|
@@ -353,7 +366,7 @@ export interface DispositionCode {
|
|
|
353
366
|
}
|
|
354
367
|
export interface DispositionRules {
|
|
355
368
|
required?: boolean;
|
|
356
|
-
notes?: "required" | "optional" | "
|
|
369
|
+
notes?: "required" | "optional" | "none";
|
|
357
370
|
codes?: DispositionCode[];
|
|
358
371
|
}
|
|
359
372
|
export interface Destination {
|
|
@@ -594,6 +607,13 @@ export type Task<C extends Channel = Channel> = {
|
|
|
594
607
|
/** The person or entity on the other end of this task. Who the task is with; `contacts` on the snapshot is the directory. */
|
|
595
608
|
party?: Contact;
|
|
596
609
|
phase: TaskPhase;
|
|
610
|
+
/**
|
|
611
|
+
* How this offer is accepted, stated on the pending task rather than the offer so a reconnect
|
|
612
|
+
* snapshot says it too: an offer the host never received is not accepted on the person's behalf
|
|
613
|
+
* for want of a word. Required while `pending` when Omni said it may auto-accept
|
|
614
|
+
* (`autoAcceptTasks: true`), forbidden when it said not, and absent past `pending`.
|
|
615
|
+
*/
|
|
616
|
+
acceptance?: AcceptanceMode;
|
|
597
617
|
/** The identifier an agent reads back to a customer, where the provider has one. */
|
|
598
618
|
reference?: string;
|
|
599
619
|
attributes?: TaskAttribute[];
|
|
@@ -609,8 +629,13 @@ export type Task<C extends Channel = Channel> = {
|
|
|
609
629
|
assisting?: never;
|
|
610
630
|
media?: never;
|
|
611
631
|
});
|
|
612
|
-
/**
|
|
613
|
-
|
|
632
|
+
/**
|
|
633
|
+
* What the provider wants of Omni's acceptance policy for one offer. Present only where Omni was
|
|
634
|
+
* willing to accept for the agent (`autoAcceptTasks: true`): `consent` is therefore always the
|
|
635
|
+
* provider's requirement of an explicit acceptance, never Omni's own policy, which travels as an
|
|
636
|
+
* absent field.
|
|
637
|
+
*/
|
|
638
|
+
export type AcceptanceMode = "no-preference" | "consent" | "automatic";
|
|
614
639
|
export type TaskOutcome = {
|
|
615
640
|
type: "completed";
|
|
616
641
|
by: "agent" | "provider";
|
|
@@ -827,7 +852,7 @@ export interface BreakState {
|
|
|
827
852
|
imposed?: ImposedBreak;
|
|
828
853
|
}
|
|
829
854
|
export type CapacityResult = {
|
|
830
|
-
status: "
|
|
855
|
+
status: "applied";
|
|
831
856
|
} | {
|
|
832
857
|
status: "failed";
|
|
833
858
|
failure: ProtocolFailure;
|
|
@@ -888,7 +913,7 @@ export interface TeamRoster {
|
|
|
888
913
|
/** A capability a team policy can name: any task control, new call, or a skill by its provider id. */
|
|
889
914
|
export type PolicyKey = Exclude<keyof TaskCapabilities<"voice">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"chat">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"email">, keyof SharedTaskCapabilities> | "dial" | `skill:${string}`;
|
|
890
915
|
/** On for everyone, off for everyone, or the agent's own choice. Only `hold`, `mute` and skills may be `agent`. */
|
|
891
|
-
export type TeamPolicySetting = "on" | "off" | "
|
|
916
|
+
export type TeamPolicySetting = "on" | "off" | "person";
|
|
892
917
|
/** 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. */
|
|
893
918
|
export interface TeamPolicy extends Resolved {
|
|
894
919
|
setting: TeamPolicySetting;
|
|
@@ -1059,7 +1084,6 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1059
1084
|
} | {
|
|
1060
1085
|
type: "task-offered";
|
|
1061
1086
|
task: Task<C>;
|
|
1062
|
-
acceptanceMode?: AcceptanceMode;
|
|
1063
1087
|
allocationExpiresAt?: IsoTimestamp;
|
|
1064
1088
|
preparationEndsAt?: IsoTimestamp;
|
|
1065
1089
|
} | {
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type Host, type HostReport, type Manifest, type ProviderEvent } from "./index.js";
|
|
1
|
+
import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type Host, type HostGuarantees, 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
|
/**
|
|
@@ -7,7 +7,7 @@ export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation }
|
|
|
7
7
|
* rules -- each optional part of a task, each optional part of the break state and roster, each
|
|
8
8
|
* declared contribution, and each event type.
|
|
9
9
|
*/
|
|
10
|
-
declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.consultation", "task.lead", "task.assisting", "task.media", "task.dispositions", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities", "team.policies"];
|
|
10
|
+
declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.consultation", "task.lead", "task.assisting", "task.media", "task.acceptance", "task.dispositions", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities", "team.policies"];
|
|
11
11
|
export type ContractSubject = (typeof STATE_SUBJECTS)[number] | `event.${ProviderEvent["type"]}`;
|
|
12
12
|
export interface AdapterContractResult {
|
|
13
13
|
events: ProviderEventEnvelope[];
|
|
@@ -126,7 +126,7 @@ export declare function assertBreakFollowsItsRequests(envelopes: readonly Provid
|
|
|
126
126
|
*/
|
|
127
127
|
export declare function assertMediaFollowsTheTask(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
|
|
128
128
|
/** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
|
|
129
|
-
export declare function stillHost(report?: HostReport): Host;
|
|
129
|
+
export declare function stillHost(report?: HostReport, guarantees?: HostGuarantees): Host;
|
|
130
130
|
/** One provider as the host sees it when freezing a break attempt's participant set. */
|
|
131
131
|
export interface BreakCandidate {
|
|
132
132
|
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, validateHostReport, validateManifest, validateResult, validateSnapshot, } from "./validation.js";
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateManifest, validateResult, validateSnapshot, } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
4
4
|
/**
|
|
5
5
|
* A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
|
|
@@ -16,6 +16,7 @@ const STATE_SUBJECTS = [
|
|
|
16
16
|
"task.lead",
|
|
17
17
|
"task.assisting",
|
|
18
18
|
"task.media",
|
|
19
|
+
"task.acceptance",
|
|
19
20
|
"task.dispositions",
|
|
20
21
|
"task.destinations",
|
|
21
22
|
"task.custom",
|
|
@@ -61,6 +62,8 @@ function observeTask(value, seen) {
|
|
|
61
62
|
seen.add("task.assisting");
|
|
62
63
|
if (value.media !== undefined)
|
|
63
64
|
seen.add("task.media");
|
|
65
|
+
if (value.acceptance !== undefined)
|
|
66
|
+
seen.add("task.acceptance");
|
|
64
67
|
const capabilities = isRecord(value.capabilities) ? value.capabilities : {};
|
|
65
68
|
if (isRecord(capabilities.dispositions))
|
|
66
69
|
seen.add("task.dispositions");
|
|
@@ -246,6 +249,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
246
249
|
// testing a host that cannot exist. Its first report and every later one are validated, a
|
|
247
250
|
// voice connection's host reports its audio and no other does, and the host the adapter
|
|
248
251
|
// receives is wrapped so the harness can tell whether the adapter ever asked.
|
|
252
|
+
violations.push(...validateHostGuarantees(context.host.guarantees, "context.host.guarantees"));
|
|
249
253
|
const first = context.host.report();
|
|
250
254
|
violations.push(...validateHostReport(first, "context.host"));
|
|
251
255
|
const hasAudio = isRecord(first) && first.audio !== undefined;
|
|
@@ -260,6 +264,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
260
264
|
});
|
|
261
265
|
let consulted = false;
|
|
262
266
|
const host = {
|
|
267
|
+
guarantees: context.host.guarantees,
|
|
263
268
|
report: () => { consulted = true; return context.host.report(); },
|
|
264
269
|
subscribe: listener => { consulted = true; return context.host.subscribe(listener); },
|
|
265
270
|
};
|
|
@@ -769,8 +774,8 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
|
|
|
769
774
|
assertNoViolations(found, "The media follows the task");
|
|
770
775
|
}
|
|
771
776
|
/** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
|
|
772
|
-
export function stillHost(report = { online: true }) {
|
|
773
|
-
return { report: () => report, subscribe: () => () => undefined };
|
|
777
|
+
export function stillHost(report = { online: true }, guarantees = {}) {
|
|
778
|
+
return { guarantees, report: () => report, subscribe: () => () => undefined };
|
|
774
779
|
}
|
|
775
780
|
const usableLogin = (status) => status === "authenticated" || status === "refreshing";
|
|
776
781
|
/**
|
package/dist/validation.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface TaskValidationContext {
|
|
|
15
15
|
channel: string;
|
|
16
16
|
/** The level ids in force, from the manifest. The defaults when absent. */
|
|
17
17
|
levels?: readonly string[];
|
|
18
|
+
/** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether a pending task states its `acceptance`. */
|
|
19
|
+
autoAcceptTasks?: boolean;
|
|
18
20
|
}
|
|
19
21
|
export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
|
|
20
22
|
/**
|
|
@@ -36,17 +38,17 @@ export interface ReaderContext {
|
|
|
36
38
|
levels?: readonly string[];
|
|
37
39
|
/** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
|
|
38
40
|
loginId?: string;
|
|
39
|
-
/** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether
|
|
41
|
+
/** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether a pending task states its `acceptance`. */
|
|
40
42
|
autoAcceptTasks?: boolean;
|
|
41
43
|
}
|
|
42
44
|
export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
43
45
|
export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
44
46
|
export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
45
47
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* hands the adapter.
|
|
48
|
+
* What a host promises. Presence is the guarantee, so a key declared `false` is refused: a
|
|
49
|
+
* promise withheld is an absent key, never a false one, exactly as a capability is.
|
|
49
50
|
*/
|
|
51
|
+
export declare function validateHostGuarantees(guarantees: unknown, path?: string): ProtocolViolation[];
|
|
50
52
|
export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
|
|
51
53
|
/** The connection methods whose results `validateResult` knows. */
|
|
52
54
|
export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia" | "setPreference" | "executeTeamPolicy";
|
package/dist/validation.js
CHANGED
|
@@ -42,7 +42,7 @@ const TASK_PHASES = membersOf({
|
|
|
42
42
|
});
|
|
43
43
|
const COMPLETION_MODES = membersOf({ "agent-command": true, "provider-automatic": true });
|
|
44
44
|
const ACCEPTANCE_MODES = membersOf({
|
|
45
|
-
"no-preference": true, "
|
|
45
|
+
"no-preference": true, "consent": true, "automatic": true,
|
|
46
46
|
});
|
|
47
47
|
const TRANSPORT_STATUSES = membersOf({ connecting: true, active: true, error: true });
|
|
48
48
|
const AUTHENTICATION_METHODS = membersOf({ "browser-sso": true, credentials: true });
|
|
@@ -61,9 +61,9 @@ const HANDLING_STEPS = membersOf({
|
|
|
61
61
|
const DESTINATION_KINDS = membersOf({ queue: true, agent: true, external: true });
|
|
62
62
|
const CUSTOM_UI_CONTROLS = membersOf({ button: true, toggle: true, "menu-item": true });
|
|
63
63
|
const CUSTOM_UI_PLACEMENTS = membersOf({ primary: true, secondary: true, overflow: true });
|
|
64
|
-
const
|
|
64
|
+
const NOTES_RULES = membersOf({ required: true, optional: true, none: true });
|
|
65
65
|
const ACCESS_MODES = membersOf({ "allow-all": true, "block-all": true });
|
|
66
|
-
const
|
|
66
|
+
const ACCESS_APPLIES_TO = membersOf({
|
|
67
67
|
"initial-url": true, "all-navigation": true,
|
|
68
68
|
});
|
|
69
69
|
const DIAL_DESTINATION_POLICIES = membersOf({ "contacts-only": true, "any-number": true });
|
|
@@ -247,8 +247,8 @@ function validateIdleCapabilities(value, channel, path, into) {
|
|
|
247
247
|
}
|
|
248
248
|
else {
|
|
249
249
|
validateBrowserAccess(browser.access, `${path}.personalBrowser.access`, into);
|
|
250
|
-
if (browser.
|
|
251
|
-
into.oneOf(browser.
|
|
250
|
+
if (browser.accessAppliesTo !== undefined) {
|
|
251
|
+
into.oneOf(browser.accessAppliesTo, ACCESS_APPLIES_TO, "manifest.personalBrowser.accessAppliesTo", `${path}.personalBrowser.accessAppliesTo`);
|
|
252
252
|
}
|
|
253
253
|
}
|
|
254
254
|
}
|
|
@@ -417,7 +417,7 @@ function validateDispositions(value, path, into) {
|
|
|
417
417
|
into.add("task.dispositions.required.codes", `${path}.codes`, "a required disposition policy must publish at least one code");
|
|
418
418
|
}
|
|
419
419
|
if (value.notes !== undefined)
|
|
420
|
-
into.oneOf(value.notes,
|
|
420
|
+
into.oneOf(value.notes, NOTES_RULES, "task.dispositions.notes", `${path}.notes`);
|
|
421
421
|
if (value.codes === undefined)
|
|
422
422
|
return;
|
|
423
423
|
if (!Array.isArray(value.codes)) {
|
|
@@ -486,11 +486,11 @@ function validateCustomCapabilities(value, path, into) {
|
|
|
486
486
|
});
|
|
487
487
|
}
|
|
488
488
|
const DEFAULT_LEVEL_IDS = DEFAULT_LEVELS.map(level => level.id);
|
|
489
|
-
const POLICY_SETTINGS = membersOf({ on: true, off: true,
|
|
489
|
+
const POLICY_SETTINGS = membersOf({ on: true, off: true, person: true });
|
|
490
490
|
const POLICY_KEYS = new Set([
|
|
491
491
|
...TASK_CAPABILITIES.voice, ...TASK_CAPABILITIES.chat, ...TASK_CAPABILITIES.email, "dial",
|
|
492
492
|
].filter(name => name !== "browsers" && name !== "dispositions" && name !== "custom"));
|
|
493
|
-
const
|
|
493
|
+
const PERSON_SETTABLE = /^(hold|mute|skill:.+)$/;
|
|
494
494
|
const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
|
|
495
495
|
/** The level ids in force: the manifest's, or the defaults when the caller holds no manifest. */
|
|
496
496
|
const levelIds = (levels) => levels ?? DEFAULT_LEVEL_IDS;
|
|
@@ -729,6 +729,20 @@ function validateTaskInto(task, context, path, into) {
|
|
|
729
729
|
into.filled(task.title, "task.title", `${path}.title`, "a task needs a title");
|
|
730
730
|
into.filled(task.taskType, "task.taskType", `${path}.taskType`, "a task needs a task type");
|
|
731
731
|
into.oneOf(task.phase, TASK_PHASES, "task.phase", `${path}.phase`);
|
|
732
|
+
// Acceptance is the offer's word, carried on the pending task so a snapshot can say it: it
|
|
733
|
+
// travels exactly when Omni said tasks may be auto-accepted, and only while the task is pending.
|
|
734
|
+
if (task.acceptance !== undefined) {
|
|
735
|
+
into.oneOf(task.acceptance, ACCEPTANCE_MODES, "task.acceptance", `${path}.acceptance`);
|
|
736
|
+
if (task.phase !== "pending") {
|
|
737
|
+
into.add("task.acceptance.unexpected", `${path}.acceptance`, "acceptance is an offer's word; a task past pending has been accepted");
|
|
738
|
+
}
|
|
739
|
+
else if (context.autoAcceptTasks === false) {
|
|
740
|
+
into.add("task.acceptance.unexpected", `${path}.acceptance`, "autoAcceptTasks is off, so every task requires agent acceptance and a pending task carries no acceptance");
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
else if (task.phase === "pending" && context.autoAcceptTasks === true) {
|
|
744
|
+
into.add("task.acceptance.required", `${path}.acceptance`, "autoAcceptTasks is on, so a pending task states how it is accepted");
|
|
745
|
+
}
|
|
732
746
|
into.oneOf(task.completionMode, COMPLETION_MODES, "task.completionMode", `${path}.completionMode`);
|
|
733
747
|
// The allowance is coupled to the mode: a provider that will complete the task itself is going
|
|
734
748
|
// to act on the allowance, so it must state one; a provider waiting for `complete` may omit it
|
|
@@ -1009,7 +1023,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1009
1023
|
const seen = new Set();
|
|
1010
1024
|
let assisting;
|
|
1011
1025
|
snapshot.tasks.forEach((task, index) => {
|
|
1012
|
-
validateTaskInto(task, { channel, levels }, `${path}.tasks[${index}]`, into);
|
|
1026
|
+
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks }, `${path}.tasks[${index}]`, into);
|
|
1013
1027
|
// A lead assists one call at a time.
|
|
1014
1028
|
if (isPlainObject(task) && task.assisting !== undefined) {
|
|
1015
1029
|
if (assisting !== undefined)
|
|
@@ -1196,28 +1210,18 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1196
1210
|
validateBreakState(event.break, `${at}.break`, into);
|
|
1197
1211
|
break;
|
|
1198
1212
|
case "task-offered":
|
|
1199
|
-
validateTaskInto(event.task, { channel, levels }, `${at}.task`, into);
|
|
1213
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks }, `${at}.task`, into);
|
|
1200
1214
|
// An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
|
|
1201
1215
|
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1202
1216
|
into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
|
|
1203
1217
|
}
|
|
1204
|
-
if (event.acceptanceMode !== undefined) {
|
|
1205
|
-
into.oneOf(event.acceptanceMode, ACCEPTANCE_MODES, "event.taskOffered.acceptanceMode", `${at}.acceptanceMode`);
|
|
1206
|
-
}
|
|
1207
|
-
// The mode travels exactly when Omni said tasks may be auto-accepted.
|
|
1208
|
-
if (context.autoAcceptTasks === true) {
|
|
1209
|
-
into.require(event.acceptanceMode !== undefined, "event.taskOffered.acceptanceMode.required", `${at}.acceptanceMode`, "autoAcceptTasks is on, so task-offered carries an acceptanceMode");
|
|
1210
|
-
}
|
|
1211
|
-
else if (context.autoAcceptTasks === false) {
|
|
1212
|
-
into.require(event.acceptanceMode === undefined, "event.taskOffered.acceptanceMode.unexpected", `${at}.acceptanceMode`, "autoAcceptTasks is off, so every task requires agent acceptance and task-offered carries no acceptanceMode");
|
|
1213
|
-
}
|
|
1214
1218
|
for (const field of ["allocationExpiresAt", "preparationEndsAt"]) {
|
|
1215
1219
|
if (event[field] !== undefined)
|
|
1216
1220
|
into.timestamp(event[field], `event.taskOffered.${field}`, `${at}.${field}`);
|
|
1217
1221
|
}
|
|
1218
1222
|
break;
|
|
1219
1223
|
case "task-updated":
|
|
1220
|
-
validateTaskInto(event.task, { channel, levels }, `${at}.task`, into);
|
|
1224
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks }, `${at}.task`, into);
|
|
1221
1225
|
break;
|
|
1222
1226
|
case "task-media-started":
|
|
1223
1227
|
into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
|
|
@@ -1320,7 +1324,7 @@ function validateTeamPoliciesInto(value, path, into, levels) {
|
|
|
1320
1324
|
continue;
|
|
1321
1325
|
}
|
|
1322
1326
|
if (into.oneOf(policy.setting, POLICY_SETTINGS, "team.policy.setting", `${at}.setting`)) {
|
|
1323
|
-
into.require(policy.setting !== "
|
|
1327
|
+
into.require(policy.setting !== "person" || PERSON_SETTABLE.test(key), "team.policy.person", `${at}.setting`, `${key} is the team's, on or off; only hold, mute and skills may be left to the person`);
|
|
1324
1328
|
}
|
|
1325
1329
|
validateResolvedInto(policy, "team.policy", at, levels, into);
|
|
1326
1330
|
into.require(policy.setBy !== "person", "team.policy.setBy", `${at}.setBy`, "a team policy is not set by a person");
|
|
@@ -1361,6 +1365,26 @@ function validateUnavailable(value, rule, path, into) {
|
|
|
1361
1365
|
* check belongs to the host's own tests and to the harness, which validates whatever host a test
|
|
1362
1366
|
* hands the adapter.
|
|
1363
1367
|
*/
|
|
1368
|
+
const HOST_GUARANTEES = membersOf({ browserUrlVisibility: true, personConsent: true });
|
|
1369
|
+
/**
|
|
1370
|
+
* What a host promises. Presence is the guarantee, so a key declared `false` is refused: a
|
|
1371
|
+
* promise withheld is an absent key, never a false one, exactly as a capability is.
|
|
1372
|
+
*/
|
|
1373
|
+
export function validateHostGuarantees(guarantees, path = "host.guarantees") {
|
|
1374
|
+
const into = new Collector();
|
|
1375
|
+
if (!isPlainObject(guarantees)) {
|
|
1376
|
+
into.add("host.guarantees.shape", path, "a host declares its guarantees as an object, empty when it makes none");
|
|
1377
|
+
return into.violations;
|
|
1378
|
+
}
|
|
1379
|
+
for (const [name, declared] of Object.entries(guarantees)) {
|
|
1380
|
+
if (declared === undefined)
|
|
1381
|
+
continue;
|
|
1382
|
+
if (!into.require(HOST_GUARANTEES.includes(name), "host.guarantee.unknown", `${path}.${name}`, `${name} is not a guarantee this contract names: ${HOST_GUARANTEES.join(", ")}`))
|
|
1383
|
+
continue;
|
|
1384
|
+
into.require(declared === true, "host.guarantee.value", `${path}.${name}`, "a guarantee is declared by presence; one the host does not make is omitted, never false");
|
|
1385
|
+
}
|
|
1386
|
+
return into.violations;
|
|
1387
|
+
}
|
|
1364
1388
|
export function validateHostReport(report, path = "host") {
|
|
1365
1389
|
const into = new Collector();
|
|
1366
1390
|
if (!isPlainObject(report)) {
|
|
@@ -1379,7 +1403,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
1379
1403
|
if (!isPlainObject(input)) {
|
|
1380
1404
|
into.add("host.audio.input.shape", at, "audio carries its input");
|
|
1381
1405
|
}
|
|
1382
|
-
else if (input.status === "
|
|
1406
|
+
else if (input.status === "available") {
|
|
1383
1407
|
into.require(typeof input.localAudio === "object" && input.localAudio !== null, "host.audio.input.localAudio", `${at}.localAudio`, "a ready input carries the captured microphone");
|
|
1384
1408
|
into.require(typeof input.flowing === "boolean", "host.audio.input.flowing", `${at}.flowing`, "a ready input says whether audio is flowing through it");
|
|
1385
1409
|
into.require(input.failure === undefined, "host.audio.input.failure.unexpected", `${at}.failure`, "a ready input carries no failure");
|
|
@@ -1392,14 +1416,14 @@ export function validateHostReport(report, path = "host") {
|
|
|
1392
1416
|
into.require(input.flowing === undefined, "host.audio.input.flowing.unexpected", `${at}.flowing`, "an unavailable input has nothing to flow");
|
|
1393
1417
|
}
|
|
1394
1418
|
else {
|
|
1395
|
-
into.add("host.audio.input.status", `${at}.status`, `an input is
|
|
1419
|
+
into.add("host.audio.input.status", `${at}.status`, `an input is available or unavailable, not ${String(input.status)}`);
|
|
1396
1420
|
}
|
|
1397
1421
|
const output = report.audio.output;
|
|
1398
1422
|
const out = `${path}.audio.output`;
|
|
1399
1423
|
if (!isPlainObject(output)) {
|
|
1400
1424
|
into.add("host.audio.output.shape", out, "audio carries its output");
|
|
1401
1425
|
}
|
|
1402
|
-
else if (output.status === "
|
|
1426
|
+
else if (output.status === "available") {
|
|
1403
1427
|
into.require(output.failure === undefined, "host.audio.output.failure.unexpected", `${out}.failure`, "a ready output carries no failure");
|
|
1404
1428
|
into.require(output.reason === undefined, "host.audio.output.reason.unexpected", `${out}.reason`, "a ready output has no reason to be unavailable");
|
|
1405
1429
|
}
|
|
@@ -1408,7 +1432,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
1408
1432
|
validateUnavailable(output, "host.audio.output", out, into);
|
|
1409
1433
|
}
|
|
1410
1434
|
else {
|
|
1411
|
-
into.add("host.audio.output.status", `${out}.status`, `an output is
|
|
1435
|
+
into.add("host.audio.output.status", `${out}.status`, `an output is available or unavailable, not ${String(output.status)}`);
|
|
1412
1436
|
}
|
|
1413
1437
|
return into.violations;
|
|
1414
1438
|
}
|
|
@@ -1417,7 +1441,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
1417
1441
|
const RESULT_STATUSES = {
|
|
1418
1442
|
execute: { success: "applied", failure: "failed" },
|
|
1419
1443
|
dial: { success: "dialled", failure: "failed" },
|
|
1420
|
-
setCapacity: { success: "
|
|
1444
|
+
setCapacity: { success: "applied", failure: "failed" },
|
|
1421
1445
|
requestBreak: { success: "requested", failure: "failed" },
|
|
1422
1446
|
commitBreak: { success: "committed", failure: "failed" },
|
|
1423
1447
|
cancelBreak: { success: "cancelled", failure: "failed" },
|
package/guide.md
CHANGED
|
@@ -155,7 +155,7 @@ type BrowserAccess = {
|
|
|
155
155
|
|
|
156
156
|
type PersonalBrowserCapability = {
|
|
157
157
|
access: BrowserAccess;
|
|
158
|
-
|
|
158
|
+
accessAppliesTo?: "initial-url" | "all-navigation";
|
|
159
159
|
};
|
|
160
160
|
|
|
161
161
|
type DialDestinations = "contacts-only" | "any-number";
|
|
@@ -253,11 +253,11 @@ type HostAudioUnavailableReason = "no-device" | "denied" | "not-asked" | "in-use
|
|
|
253
253
|
type HostOutputUnavailableReason = "no-device" | "lost";
|
|
254
254
|
|
|
255
255
|
type HostAudioInput =
|
|
256
|
-
| { status: "
|
|
256
|
+
| { status: "available"; localAudio: MediaStream; flowing: boolean }
|
|
257
257
|
| { status: "unavailable"; reason: HostAudioUnavailableReason; failure: ProtocolFailure };
|
|
258
258
|
|
|
259
259
|
type HostAudioOutput =
|
|
260
|
-
| { status: "
|
|
260
|
+
| { status: "available" }
|
|
261
261
|
| { status: "unavailable"; reason: HostOutputUnavailableReason; failure: ProtocolFailure };
|
|
262
262
|
|
|
263
263
|
type UrlVisibility = "full" | "domain" | "hidden";
|
|
@@ -270,7 +270,13 @@ type HostReport = {
|
|
|
270
270
|
};
|
|
271
271
|
};
|
|
272
272
|
|
|
273
|
+
type HostGuarantees = {
|
|
274
|
+
browserUrlVisibility?: true;
|
|
275
|
+
personConsent?: true;
|
|
276
|
+
};
|
|
277
|
+
|
|
273
278
|
type Host = {
|
|
279
|
+
guarantees: HostGuarantees;
|
|
274
280
|
report(): HostReport;
|
|
275
281
|
subscribe(listener: (report: HostReport) => void): Unsubscribe;
|
|
276
282
|
};
|
|
@@ -315,7 +321,7 @@ type CompleteAuthenticationResult =
|
|
|
315
321
|
| { status: "rejected"; failure: AuthenticationFailure };
|
|
316
322
|
|
|
317
323
|
type AuthenticationActionResult =
|
|
318
|
-
| { status: "
|
|
324
|
+
| { status: "applied" }
|
|
319
325
|
| { status: "failed"; failure: AuthenticationFailure };
|
|
320
326
|
|
|
321
327
|
type Unsubscribe = () => void;
|
|
@@ -374,7 +380,7 @@ type AgentCapacity = {
|
|
|
374
380
|
};
|
|
375
381
|
|
|
376
382
|
type CapacityResult =
|
|
377
|
-
| { status: "
|
|
383
|
+
| { status: "applied" }
|
|
378
384
|
| { status: "failed"; failure: ProtocolFailure };
|
|
379
385
|
```
|
|
380
386
|
|
|
@@ -413,7 +419,7 @@ type DispositionCode = { id: string; label: string; group?: string };
|
|
|
413
419
|
|
|
414
420
|
type DispositionRules = {
|
|
415
421
|
required?: boolean;
|
|
416
|
-
notes?: "required" | "optional" | "
|
|
422
|
+
notes?: "required" | "optional" | "none";
|
|
417
423
|
codes?: DispositionCode[];
|
|
418
424
|
};
|
|
419
425
|
|
|
@@ -598,6 +604,7 @@ type Task<C extends Channel = Channel> = {
|
|
|
598
604
|
browsers: TaskBrowser[];
|
|
599
605
|
party?: Contact;
|
|
600
606
|
phase: TaskPhase;
|
|
607
|
+
acceptance?: AcceptanceMode;
|
|
601
608
|
reference?: string;
|
|
602
609
|
attributes?: TaskAttribute[];
|
|
603
610
|
handlingHistory?: TaskHandlingStep[];
|
|
@@ -609,8 +616,8 @@ type Task<C extends Channel = Channel> = {
|
|
|
609
616
|
|
|
610
617
|
type AcceptanceMode =
|
|
611
618
|
| "no-preference"
|
|
612
|
-
| "
|
|
613
|
-
| "
|
|
619
|
+
| "consent"
|
|
620
|
+
| "automatic";
|
|
614
621
|
|
|
615
622
|
type TaskOutcome =
|
|
616
623
|
| { type: "completed"; by: "agent" | "provider" }
|
|
@@ -747,8 +754,8 @@ controls — is content, and is never locked.
|
|
|
747
754
|
|
|
748
755
|
**A lead sets the team's policy from their roster.** A login that declares
|
|
749
756
|
`capabilities.team.policyControl` may `executeTeamPolicy({ type: "set", capability, setting })`
|
|
750
|
-
with `on`, `off`, or `
|
|
751
|
-
and skills may be `
|
|
757
|
+
with `on`, `off`, or `person`, for any task control, `dial`, or a skill — and only `hold`, `mute`
|
|
758
|
+
and skills may be `person`; callback and new call are the team's, on or off, within what the queue
|
|
752
759
|
allows. The roster carries `policies` for such a login: every policy as it stands, who set it, and
|
|
753
760
|
`lockedBy` where a level above the team made it theirs to keep, which the lead sees and cannot
|
|
754
761
|
change — `executeTeamPolicy` on it answers `failed` with `omni.capability-not-enabled`.
|
|
@@ -856,7 +863,7 @@ type PolicyKey =
|
|
|
856
863
|
| "dial"
|
|
857
864
|
| `skill:${string}`;
|
|
858
865
|
|
|
859
|
-
type TeamPolicySetting = "on" | "off" | "
|
|
866
|
+
type TeamPolicySetting = "on" | "off" | "person";
|
|
860
867
|
|
|
861
868
|
type TeamPolicy = Resolved & {
|
|
862
869
|
setting: TeamPolicySetting;
|
|
@@ -935,7 +942,6 @@ type ProviderEvent =
|
|
|
935
942
|
| {
|
|
936
943
|
type: "task-offered";
|
|
937
944
|
task: Task;
|
|
938
|
-
acceptanceMode?: AcceptanceMode;
|
|
939
945
|
allocationExpiresAt?: IsoTimestamp;
|
|
940
946
|
preparationEndsAt?: IsoTimestamp;
|
|
941
947
|
}
|
|
@@ -1474,12 +1480,12 @@ idleCapabilities: {
|
|
|
1474
1480
|
| `access.mode` | `allow-all` permits unmatched URLs; `block-all` denies unmatched URLs. |
|
|
1475
1481
|
| `access.allowList` | URL-pattern exceptions permitted when the mode is `block-all`. |
|
|
1476
1482
|
| `access.blockList` | Explicit denials. A match takes precedence over the same policy's allow list and mode. |
|
|
1477
|
-
| `
|
|
1483
|
+
| `accessAppliesTo` | `all-navigation` by default: every redirect and navigation is checked. `initial-url` checks the starting URL alone. |
|
|
1478
1484
|
|
|
1479
1485
|
Patterns use the standard `URLPattern` syntax. Omni owns browser navigation, and the browser is
|
|
1480
1486
|
hidden when no active provider contributes one.
|
|
1481
1487
|
|
|
1482
|
-
`
|
|
1488
|
+
`accessAppliesTo` defaults to `all-navigation`: every redirect and subsequent navigation is
|
|
1483
1489
|
validated against the current combined policy, not only the starting URL. A provider may set
|
|
1484
1490
|
`initial-url` to check the first hop alone, but that has to be asked for. A `block-all` policy
|
|
1485
1491
|
enforced only on the initial URL stops nothing — one redirect leaves it — so the permissive
|
|
@@ -1682,7 +1688,7 @@ them from what arrives later.
|
|
|
1682
1688
|
| `team` | This login leads a team. The provider publishes a `TeamRoster` to it on every snapshot — `[]` when nobody is in it — and to nobody else. |
|
|
1683
1689
|
| `team.breakControl` | This lead may act on their team's breaks through `executeTeamBreak` — place, release, decide, set policy — as far as the provider supports; a command it lacks answers `omni.capability-not-enabled`. Omni asks for a decision only against a member whose `break` is `awaiting-decision`, so a provider that grants on request is never asked to decide. Requires `executeTeamBreak`. |
|
|
1684
1690
|
| `team.consultControl` | This lead may join a member's call on request. Requires `executeTeamConsult`. |
|
|
1685
|
-
| `team.policyControl` | This lead sets the team's policy per capability — on, off, or the
|
|
1691
|
+
| `team.policyControl` | This lead sets the team's policy per capability — on, off, or the person's — within what the queue allows. Requires `executeTeamPolicy`; the roster carries `policies`. |
|
|
1686
1692
|
| `preferences` | What the team left to this person, with where each stands and who set it. Omitted when nothing was. Requires `setPreference`. See **Who decides what an agent may do**. |
|
|
1687
1693
|
|
|
1688
1694
|
A session action is available only when both the capability and Omni provisioning permit it.
|
|
@@ -1765,7 +1771,7 @@ settles. The adapter must not persist raw credentials. Field-specific failures m
|
|
|
1765
1771
|
|
|
1766
1772
|
`cancelAuthentication(flowId)` cancels an abandoned Browser SSO window or credentials form and
|
|
1767
1773
|
releases its temporary state. It does not sign out an already authenticated session. It answers
|
|
1768
|
-
`
|
|
1774
|
+
`applied`; a repeat, or a flow that already ended, is nothing to act on and answers `applied`
|
|
1769
1775
|
too, by the rule that a command asking for a state answers success when that state holds.
|
|
1770
1776
|
|
|
1771
1777
|
### Completion and failures
|
|
@@ -1824,7 +1830,7 @@ Creates one live provider connection for the signed-in agent.
|
|
|
1824
1830
|
| --- | --- |
|
|
1825
1831
|
| `protocolVersion` | Version negotiated before authentication. Fixed for this login. |
|
|
1826
1832
|
| `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. |
|
|
1827
|
-
| `autoAcceptTasks` | Agent provisioning policy relayed to the provider at login. Treated as `true` when omitted. When `true`,
|
|
1833
|
+
| `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()`. |
|
|
1828
1834
|
| `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**. |
|
|
1829
1835
|
| `signal` | Optional cancellation signal. Stop startup promptly when aborted and do not begin new work. |
|
|
1830
1836
|
| `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
|
|
@@ -1968,40 +1974,45 @@ deliver again once it has gone quiet. Hold the capacity and deliver when work ar
|
|
|
1968
1974
|
**4. A provider offers a task.** The provider emits `task-offered` within the stated capacity.
|
|
1969
1975
|
|
|
1970
1976
|
**5. Omni decides how the task is accepted.** When `autoAcceptTasks` is `false`, every task requires
|
|
1971
|
-
agent acceptance. When it is `true`, the
|
|
1977
|
+
agent acceptance. When it is `true`, the pending task's `acceptance` states the provider's
|
|
1972
1978
|
intent.
|
|
1973
1979
|
|
|
1974
1980
|
### Acceptance modes
|
|
1975
1981
|
|
|
1976
1982
|
During login, Omni sends the agent's `autoAcceptTasks` value to the provider. When it is `true`, the
|
|
1977
|
-
provider
|
|
1983
|
+
provider states `acceptance` on each pending task — on the task rather than the offer, so a
|
|
1984
|
+
reconnect snapshot says it too and an offer the host never received is not accepted on the
|
|
1985
|
+
person's behalf for want of a word:
|
|
1978
1986
|
|
|
1979
1987
|
| Directive | Contract |
|
|
1980
1988
|
| --- | --- |
|
|
1981
1989
|
| `no-preference` | The provider leaves acceptance to Omni; with `autoAcceptTasks: true`, Omni accepts automatically. |
|
|
1982
|
-
| `
|
|
1983
|
-
| `
|
|
1990
|
+
| `consent` | The provider requires the person's explicit consent: Omni presents **Accept** and waits, whatever its own policy would have done. A host that declares `guarantees.personConsent` promises exactly this; a provider checks it before offering work only a person may take. |
|
|
1991
|
+
| `automatic` | Omni accepts immediately without agent interaction. |
|
|
1984
1992
|
|
|
1985
|
-
When Omni sent `autoAcceptTasks: false`, the provider omits `
|
|
1986
|
-
requires agent acceptance.
|
|
1993
|
+
When Omni sent `autoAcceptTasks: false`, the provider omits `acceptance` and every task
|
|
1994
|
+
requires agent acceptance. The two are never confused on the wire: `consent` is always the
|
|
1995
|
+
provider's requirement, stated on a wire where Omni was willing to accept for the agent; Omni's own
|
|
1996
|
+
no-auto-accept policy puts no word on the wire at all — the field is absent, and the **Accept**
|
|
1997
|
+
press is Omni's doing, not the provider's.
|
|
1987
1998
|
|
|
1988
1999
|
**An absent value means `true`**, as `readyOnLogin` does, because an agent who has signed in and
|
|
1989
2000
|
gone ready is telling the deployment they are working. Requiring a press before every contact is
|
|
1990
2001
|
the exception a provisioning file asks for, not the state it falls into when a flag is missing.
|
|
1991
2002
|
|
|
1992
|
-
Nothing is given away by that default. `
|
|
1993
|
-
it: `
|
|
2003
|
+
Nothing is given away by that default. `acceptance` is the provider's own control and outranks
|
|
2004
|
+
it: `consent` puts the decision back in the agent's hands for any task where it
|
|
1994
2005
|
belongs, whatever the host was configured with.
|
|
1995
2006
|
|
|
1996
2007
|
An automatically accepted task still arrives through `task-offered`.
|
|
1997
2008
|
|
|
1998
|
-
Agent-initiated work arrives through `task-offered` with
|
|
1999
|
-
`
|
|
2009
|
+
Agent-initiated work arrives through `task-offered` with the task's
|
|
2010
|
+
`acceptance: "automatic"`.
|
|
2000
2011
|
|
|
2001
2012
|
### Pending
|
|
2002
2013
|
|
|
2003
2014
|
A task in the `pending` phase has been **offered to the agent and not yet accepted**. Omni applies
|
|
2004
|
-
`autoAcceptTasks` and the
|
|
2015
|
+
`autoAcceptTasks` and the task's `acceptance` to decide whether acceptance is
|
|
2005
2016
|
automatic or requires the agent. A provider that requires automatic acceptance still emits
|
|
2006
2017
|
`task-offered`; it does not introduce new work as `in-progress`.
|
|
2007
2018
|
|
|
@@ -2010,8 +2021,7 @@ declare const task: Task;
|
|
|
2010
2021
|
|
|
2011
2022
|
const allocation = {
|
|
2012
2023
|
type: "task-offered",
|
|
2013
|
-
task,
|
|
2014
|
-
acceptanceMode: "require-agent-acceptance",
|
|
2024
|
+
task: { ...task, phase: "pending", acceptance: "consent" },
|
|
2015
2025
|
allocationExpiresAt: "2026-08-25T10:41:07.000Z",
|
|
2016
2026
|
preparationEndsAt: "2026-08-25T10:40:37.000Z",
|
|
2017
2027
|
} satisfies Extract<ProviderEvent, { type: "task-offered" }>;
|
|
@@ -2021,7 +2031,7 @@ The rule the phase exists to express: **nothing is acquired on the agent's behal
|
|
|
2021
2031
|
is pending.** A host that carries media must not open the microphone until the task is
|
|
2022
2032
|
accepted. Omni does not open the task's browsers either — a task that rings out costs nothing.
|
|
2023
2033
|
|
|
2024
|
-
When
|
|
2034
|
+
When consent is required, Omni offers the agent an **Accept** control. The call is the
|
|
2025
2035
|
medium the task arrives on, not a separate decision.
|
|
2026
2036
|
|
|
2027
2037
|
**Once a task is accepted, the call that comes with it is answered.** Omni has no discretion
|
|
@@ -2074,6 +2084,7 @@ time. Runtime conformance checks also require the task channel to match its prov
|
|
|
2074
2084
|
| `party` | The person or entity on the other end of this task, as a `Contact`: often a name and one address; a withheld caller ID may leave nothing to send at all. Optional. The party is who the task is *with*; `contacts` is the directory. |
|
|
2075
2085
|
| `phase` | Current canonical task phase: `pending`, `confirmed`, `preparing`, `in-progress`, `paused`, or `completing`. |
|
|
2076
2086
|
| `media` | Voice only. The task's real-time audio as the provider holds it: `started` while audio is attached, `ended` once it ended, omitted while none is. The provider's word — see **`task-media-started`**. |
|
|
2087
|
+
| `acceptance` | How this offer is accepted — `no-preference`, `consent`, or `automatic` — stated on the pending task so a reconnect snapshot says it too. Required while `pending` when `autoAcceptTasks` was `true`, forbidden when it was `false`, and absent past `pending`. See **Acceptance modes**. |
|
|
2077
2088
|
| `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
|
|
2078
2089
|
| `completionMode` | `agent-command` waits for the channel's `complete` command; `provider-automatic` completes without one. |
|
|
2079
2090
|
| `wrapAllowance` | Fixed time allowed to complete the task after primary handling ends. For real-time media, it begins after `task-media-ended`. Required under `provider-automatic`, where the provider acts on it. Optional under `agent-command`: omitted says the provider imposes no deadline, and Omni counts nothing down. |
|
|
@@ -2125,12 +2136,12 @@ The canonical task transitions are:
|
|
|
2125
2136
|
| Any phase | Provider emits `task-ended` | Removed |
|
|
2126
2137
|
|
|
2127
2138
|
Allocation, acceptance, and progress are distinct. Acceptance follows `autoAcceptTasks` and the
|
|
2128
|
-
|
|
2139
|
+
task's `acceptance`, moving the task from `pending` to `confirmed`. The provider reports
|
|
2129
2140
|
subsequent transitions to `preparing` or `in-progress`; Omni does not infer them from the acceptance
|
|
2130
2141
|
command.
|
|
2131
2142
|
|
|
2132
2143
|
**A task is never its audio.** A voice task is the allocation: the call is offered when it is
|
|
2133
|
-
routed to the agent and accepted as `
|
|
2144
|
+
routed to the agent and accepted as its `acceptance` dictates, and its presence and phase follow
|
|
2134
2145
|
the provider's reports about the work — never the audio. Wherever audio moves — an offer, a hold, a
|
|
2135
2146
|
consult, a conference leg joining or leaving, a transfer, a callback — the media follows
|
|
2136
2147
|
separately, arriving on `task-media-started`, attaching through `openMedia` and ending with
|
|
@@ -2331,7 +2342,7 @@ Each `TaskBrowser` defines one named browser in the task workspace.
|
|
|
2331
2342
|
| `url` | Initial URL. Must use `http:` or `https:`; see below. Later navigation comes from Chromium. |
|
|
2332
2343
|
| `sharedSession` | Required. `false` creates a task-specific browser session. |
|
|
2333
2344
|
| `isolationScheme` | **Required when `sharedSession` is `true`**, and rejected when it is `false`. There is no default: see below. |
|
|
2334
|
-
| `urlVisibility` | What the agent sees of this tab's URL in Omni's chrome: `hidden`, `domain`, or `full`. Omitted, the URL shows as any browser's does; a provider says `hidden` where the URL carries what the agent may not read — a caller's number, a CRM token. Per browser, on the provider's word;
|
|
2345
|
+
| `urlVisibility` | What the agent sees of this tab's URL in Omni's chrome: `hidden`, `domain`, or `full`. Omitted, the URL shows as any browser's does; a provider says `hidden` where the URL carries what the agent may not read — a caller's number, a CRM token. Per browser, on the provider's word; a host that declares `guarantees.browserUrlVisibility` honours it tab by tab, and a provider checks that guarantee before it sends such a URL at all. |
|
|
2335
2346
|
|
|
2336
2347
|
##### Choosing an isolation scheme
|
|
2337
2348
|
|
|
@@ -2464,7 +2475,7 @@ capabilities: {
|
|
|
2464
2475
|
| Field | Contract |
|
|
2465
2476
|
| --- | --- |
|
|
2466
2477
|
| `required` | When `true`, Omni must collect a code before issuing `complete`. A required policy must publish at least one code. |
|
|
2467
|
-
| `notes` | `required`, `optional`, or `
|
|
2478
|
+
| `notes` | `required`, `optional`, or `none`; controls the free-text field beside the code. |
|
|
2468
2479
|
| `codes` | Codes Omni offers. `id` values are non-empty and unique; Omni sends the chosen `id` as `TaskCommand.complete.disposition`. |
|
|
2469
2480
|
|
|
2470
2481
|
With `dispositions: true` Omni shows a Complete control and sends `complete` with no code, because
|
|
@@ -2693,7 +2704,7 @@ everywhere; success is not.
|
|
|
2693
2704
|
|
|
2694
2705
|
| Method | Succeeded |
|
|
2695
2706
|
| --- | --- |
|
|
2696
|
-
| `setCapacity` | `
|
|
2707
|
+
| `setCapacity` | `applied` |
|
|
2697
2708
|
| `requestBreak` | `requested` |
|
|
2698
2709
|
| `commitBreak` | `committed` |
|
|
2699
2710
|
| `cancelBreak` | `cancelled` |
|
|
@@ -3080,7 +3091,7 @@ executeTeamConsult({ command: { type: "decline", requestId: "req-7", reason: "In
|
|
|
3080
3091
|
```
|
|
3081
3092
|
|
|
3082
3093
|
**On `join` the provider bridges three parties and the lead is on a task of their own**, on the
|
|
3083
|
-
same task id, arriving on the lead's connection as `task-offered` with `
|
|
3094
|
+
same task id, arriving on the lead's connection as `task-offered` with `automatic`
|
|
3084
3095
|
-- the way a call an agent placed themselves arrives -- and carrying `assisting`. The agent's task
|
|
3085
3096
|
moves to `lead: { stage: "joined", leadId }`. A join is the lead's own act, so capacity does not
|
|
3086
3097
|
trigger it; but from then on it is an outstanding task the provider counts against the lead's
|
|
@@ -3190,12 +3201,27 @@ the microphone once as the voice connection opens, so the permission prompt land
|
|
|
3190
3201
|
is signing in rather than over a contact, prompts, retries on the agent's request, tells the agent
|
|
3191
3202
|
what failed, and reports. It never decides for the adapter what a missing microphone means.
|
|
3192
3203
|
|
|
3204
|
+
**The host also guarantees, and a promise the provider cannot see is not one it can rely on.**
|
|
3205
|
+
Two of this contract's obligations fall on the host rather than the provider — honouring a task
|
|
3206
|
+
browser's `urlVisibility`, and taking a `consent` offer only on the person's own press — and more
|
|
3207
|
+
than one desk speaks this contract. So `ConnectContext.host.guarantees` says which promises the
|
|
3208
|
+
connected host makes, declared once per connection, presence being the guarantee exactly as it is
|
|
3209
|
+
the permission everywhere else:
|
|
3210
|
+
|
|
3211
|
+
| Guarantee | Contract |
|
|
3212
|
+
| --- | --- |
|
|
3213
|
+
| `browserUrlVisibility` | Every task browser's `urlVisibility` is honoured in this host's chrome, tab by tab. A provider that would send a caller's number in a URL checks this first and tokenises where the promise is absent. |
|
|
3214
|
+
| `personConsent` | A `consent` offer is accepted only by the person's own explicit act, never on their behalf. A provider whose work may only be taken by a human checks this first and does not offer it where the promise is absent. |
|
|
3215
|
+
|
|
3216
|
+
A guarantee the host does not make is an absent key, never `false` — `validateHostGuarantees`
|
|
3217
|
+
refuses a false one, as it refuses a name this contract does not list.
|
|
3218
|
+
|
|
3193
3219
|
| Field | Contract |
|
|
3194
3220
|
| --- | --- |
|
|
3195
3221
|
| `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. |
|
|
3196
3222
|
| `audio` | Present on a voice connection, absent where there is no audio. |
|
|
3197
|
-
| `audio.input` | `
|
|
3198
|
-
| `audio.output` | `
|
|
3223
|
+
| `audio.input` | `available` with `localAudio` — the microphone as captured, the same stream `openMedia` receives — and `flowing`, false while the hardware or OS says no audio moves through it (a headset's own mute switch, which Omni's Mute control never touches). `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. |
|
|
3224
|
+
| `audio.output` | `available`, 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. |
|
|
3199
3225
|
|
|
3200
3226
|
Omni republishes the report whenever it changes — a permission granted late, a headset unplugged,
|
|
3201
3227
|
a network gone — and it publishes a state, not a flicker: a change that resolves within moments is
|
|
@@ -3524,8 +3550,8 @@ multi-provider break**.
|
|
|
3524
3550
|
### `task-offered`
|
|
3525
3551
|
|
|
3526
3552
|
Offers a task to Omni without a separate offer acknowledgement. An offer does not accept
|
|
3527
|
-
the task: when its phase is `pending`, Omni applies `autoAcceptTasks` and the
|
|
3528
|
-
`
|
|
3553
|
+
the task: when its phase is `pending`, Omni applies `autoAcceptTasks` and the task's
|
|
3554
|
+
`acceptance`. `task-offered` must not introduce a task as `in-progress`; only a reconnect or
|
|
3529
3555
|
resync snapshot may report work already in progress. The provider should include the task in later
|
|
3530
3556
|
snapshots until it ends.
|
|
3531
3557
|
|
|
@@ -3647,7 +3673,8 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
3647
3673
|
| `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
|
|
3648
3674
|
| `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
|
|
3649
3675
|
| `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
|
|
3650
|
-
| `
|
|
3676
|
+
| `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. |
|
|
3677
|
+
| `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. |
|
|
3651
3678
|
| `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. |
|
|
3652
3679
|
| `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. |
|
|
3653
3680
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xema/omni-protocol",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.29",
|
|
4
4
|
"description": "The Omni protocol: the contract every provider adapter implements",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"scripts": {
|
|
31
31
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
32
32
|
"build": "pnpm run clean && tsc -p tsconfig.json",
|
|
33
|
-
"test": "tsc -p tsconfig.
|
|
33
|
+
"test": "tsc -p tsconfig.test.json && vitest run"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@types/node": "^22.20.1",
|