@xema/omni-protocol 0.1.26 → 0.1.28
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 +38 -18
- package/dist/index.js +2 -2
- package/dist/testing.d.ts +3 -3
- package/dist/testing.js +10 -8
- package/dist/validation.d.ts +3 -3
- package/dist/validation.js +45 -25
- package/guide.md +73 -48
- package/package.json +1 -1
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
|
}
|
|
@@ -342,7 +355,8 @@ export interface ScheduledActivity {
|
|
|
342
355
|
title: string;
|
|
343
356
|
startsAt: IsoTimestamp;
|
|
344
357
|
endsAt?: IsoTimestamp;
|
|
345
|
-
|
|
358
|
+
/** The person the activity reaches -- a callback's customer. */
|
|
359
|
+
party?: Contact;
|
|
346
360
|
attributes?: Attribute[];
|
|
347
361
|
}
|
|
348
362
|
export interface DispositionCode {
|
|
@@ -352,7 +366,7 @@ export interface DispositionCode {
|
|
|
352
366
|
}
|
|
353
367
|
export interface DispositionRules {
|
|
354
368
|
required?: boolean;
|
|
355
|
-
notes?: "required" | "optional" | "
|
|
369
|
+
notes?: "required" | "optional" | "none";
|
|
356
370
|
codes?: DispositionCode[];
|
|
357
371
|
}
|
|
358
372
|
export interface Destination {
|
|
@@ -443,10 +457,10 @@ export type TaskBrowser = Browser & {
|
|
|
443
457
|
/** Hide this tab's URL from the agent in Omni's chrome. Omitted, the URL shows as any browser's does; a provider says `hidden` where the URL carries what the agent may not read. */
|
|
444
458
|
urlVisibility?: UrlVisibility;
|
|
445
459
|
} & ({
|
|
446
|
-
|
|
460
|
+
sharedSession: false;
|
|
447
461
|
isolationScheme?: never;
|
|
448
462
|
} | {
|
|
449
|
-
|
|
463
|
+
sharedSession: true;
|
|
450
464
|
isolationScheme: BrowserIsolationScheme;
|
|
451
465
|
});
|
|
452
466
|
/** A tab the agent opened in the personal workspace: theirs, as many as they like, and never on the wire. */
|
|
@@ -473,7 +487,7 @@ export type TaskAttribute = TaskAttributeBase & ({
|
|
|
473
487
|
value: string;
|
|
474
488
|
} | {
|
|
475
489
|
type: "contact";
|
|
476
|
-
|
|
490
|
+
party: Contact;
|
|
477
491
|
} | {
|
|
478
492
|
type: "timestamp";
|
|
479
493
|
at: IsoTimestamp;
|
|
@@ -590,7 +604,8 @@ export type Task<C extends Channel = Channel> = {
|
|
|
590
604
|
taskType: string;
|
|
591
605
|
capabilities: TaskCapabilities<C>;
|
|
592
606
|
browsers: TaskBrowser[];
|
|
593
|
-
|
|
607
|
+
/** The person or entity on the other end of this task. Who the task is with; `contacts` on the snapshot is the directory. */
|
|
608
|
+
party?: Contact;
|
|
594
609
|
phase: TaskPhase;
|
|
595
610
|
/** The identifier an agent reads back to a customer, where the provider has one. */
|
|
596
611
|
reference?: string;
|
|
@@ -607,8 +622,13 @@ export type Task<C extends Channel = Channel> = {
|
|
|
607
622
|
assisting?: never;
|
|
608
623
|
media?: never;
|
|
609
624
|
});
|
|
610
|
-
/**
|
|
611
|
-
|
|
625
|
+
/**
|
|
626
|
+
* What the provider wants of Omni's acceptance policy for one offer. Present only where Omni was
|
|
627
|
+
* willing to accept for the agent (`autoAcceptTasks: true`): `consent` is therefore always the
|
|
628
|
+
* provider's requirement of an explicit acceptance, never Omni's own policy, which travels as an
|
|
629
|
+
* absent field.
|
|
630
|
+
*/
|
|
631
|
+
export type AcceptanceMode = "no-preference" | "consent" | "automatic";
|
|
612
632
|
export type TaskOutcome = {
|
|
613
633
|
type: "completed";
|
|
614
634
|
by: "agent" | "provider";
|
|
@@ -825,7 +845,7 @@ export interface BreakState {
|
|
|
825
845
|
imposed?: ImposedBreak;
|
|
826
846
|
}
|
|
827
847
|
export type CapacityResult = {
|
|
828
|
-
status: "
|
|
848
|
+
status: "applied";
|
|
829
849
|
} | {
|
|
830
850
|
status: "failed";
|
|
831
851
|
failure: ProtocolFailure;
|
|
@@ -886,7 +906,7 @@ export interface TeamRoster {
|
|
|
886
906
|
/** A capability a team policy can name: any task control, new call, or a skill by its provider id. */
|
|
887
907
|
export type PolicyKey = Exclude<keyof TaskCapabilities<"voice">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"chat">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"email">, keyof SharedTaskCapabilities> | "dial" | `skill:${string}`;
|
|
888
908
|
/** On for everyone, off for everyone, or the agent's own choice. Only `hold`, `mute` and skills may be `agent`. */
|
|
889
|
-
export type TeamPolicySetting = "on" | "off" | "
|
|
909
|
+
export type TeamPolicySetting = "on" | "off" | "person";
|
|
890
910
|
/** 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. */
|
|
891
911
|
export interface TeamPolicy extends Resolved {
|
|
892
912
|
setting: TeamPolicySetting;
|
|
@@ -1031,7 +1051,7 @@ export interface SummaryMetric {
|
|
|
1031
1051
|
label: string;
|
|
1032
1052
|
value: string;
|
|
1033
1053
|
}
|
|
1034
|
-
export interface
|
|
1054
|
+
export interface QueueSummary {
|
|
1035
1055
|
title: string;
|
|
1036
1056
|
subtitle?: string;
|
|
1037
1057
|
waitingCount: number;
|
|
@@ -1080,8 +1100,8 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1080
1100
|
announcedAt: IsoTimestamp;
|
|
1081
1101
|
expiresAt?: IsoTimestamp;
|
|
1082
1102
|
} | {
|
|
1083
|
-
type: "
|
|
1084
|
-
summary:
|
|
1103
|
+
type: "queue-summary";
|
|
1104
|
+
summary: QueueSummary;
|
|
1085
1105
|
} | {
|
|
1086
1106
|
type: "team-updated";
|
|
1087
1107
|
team: TeamRoster;
|
|
@@ -1229,7 +1249,7 @@ export declare function sameCapabilities(a: UserCapabilities, b: UserCapabilitie
|
|
|
1229
1249
|
/**
|
|
1230
1250
|
* The storage-profile key a reusing browser shares, or `undefined` where it shares nothing.
|
|
1231
1251
|
*
|
|
1232
|
-
* Fails closed. A browser with `
|
|
1252
|
+
* Fails closed. A browser with `sharedSession: false` has no key; nor does a reusing one whose scheme is
|
|
1233
1253
|
* missing or unknown -- the type forbids that, but an adapter compiled against another version can
|
|
1234
1254
|
* still send it, and the safe reading is "do not share", never "share with everyone named the
|
|
1235
1255
|
* same". Every part is encoded, separator included, before joining, so a tab called `a.b`
|
package/dist/index.js
CHANGED
|
@@ -157,7 +157,7 @@ export function sameCapabilities(a, b) {
|
|
|
157
157
|
/**
|
|
158
158
|
* The storage-profile key a reusing browser shares, or `undefined` where it shares nothing.
|
|
159
159
|
*
|
|
160
|
-
* Fails closed. A browser with `
|
|
160
|
+
* Fails closed. A browser with `sharedSession: false` has no key; nor does a reusing one whose scheme is
|
|
161
161
|
* missing or unknown -- the type forbids that, but an adapter compiled against another version can
|
|
162
162
|
* still send it, and the safe reading is "do not share", never "share with everyone named the
|
|
163
163
|
* same". Every part is encoded, separator included, before joining, so a tab called `a.b`
|
|
@@ -165,7 +165,7 @@ export function sameCapabilities(a, b) {
|
|
|
165
165
|
*/
|
|
166
166
|
export function browserSessionKey(input) {
|
|
167
167
|
const { providerId, taskId, taskType, browser } = input;
|
|
168
|
-
if (browser.
|
|
168
|
+
if (browser.sharedSession !== true)
|
|
169
169
|
return undefined;
|
|
170
170
|
// `encodeURIComponent` leaves `.` untouched, and `.` is the separator: a raw join would let
|
|
171
171
|
// provider `Acme.Voice` with type `Support` forge the key of `Acme` with `Voice.Support`.
|
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
|
/**
|
|
@@ -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;
|
|
@@ -166,7 +166,7 @@ export declare function assertWrapTimeout(task: Pick<TaskCompletion, "completion
|
|
|
166
166
|
/** One browser in one task of one provider. `providerId` is `Manifest.id`, never `displayName`. */
|
|
167
167
|
export type BrowserIsolationScenario = BrowserSessionKeyInput;
|
|
168
168
|
/** Validates whether two task-browser definitions should share one browser session. */
|
|
169
|
-
export declare function
|
|
169
|
+
export declare function assertBrowserSessionIsolation(left: BrowserIsolationScenario, right: BrowserIsolationScenario, expectedReuse: boolean): void;
|
|
170
170
|
/**
|
|
171
171
|
* Asserts that no two distinct scenarios in `scenarios` derive the same session key.
|
|
172
172
|
* Feed it adversarial names — a provider called `A.B` against a task type called
|
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
|
|
@@ -32,7 +32,7 @@ const STATE_SUBJECTS = [
|
|
|
32
32
|
// `ProviderEvent` without a row here, or a row it lacks, is a compile error.
|
|
33
33
|
const EVENT_TYPES = {
|
|
34
34
|
snapshot: true, "transport-status": true, "break-state": true, "task-offered": true, "task-updated": true,
|
|
35
|
-
"task-media-started": true, "task-media-ended": true, "task-ended": true, announcement: true, "
|
|
35
|
+
"task-media-started": true, "task-media-ended": true, "task-ended": true, announcement: true, "queue-summary": true,
|
|
36
36
|
"team-updated": true, "contacts-updated": true, "calendar-updated": true,
|
|
37
37
|
};
|
|
38
38
|
const CONTRACT_SUBJECTS = [
|
|
@@ -246,6 +246,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
246
246
|
// testing a host that cannot exist. Its first report and every later one are validated, a
|
|
247
247
|
// voice connection's host reports its audio and no other does, and the host the adapter
|
|
248
248
|
// receives is wrapped so the harness can tell whether the adapter ever asked.
|
|
249
|
+
violations.push(...validateHostGuarantees(context.host.guarantees, "context.host.guarantees"));
|
|
249
250
|
const first = context.host.report();
|
|
250
251
|
violations.push(...validateHostReport(first, "context.host"));
|
|
251
252
|
const hasAudio = isRecord(first) && first.audio !== undefined;
|
|
@@ -260,6 +261,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
260
261
|
});
|
|
261
262
|
let consulted = false;
|
|
262
263
|
const host = {
|
|
264
|
+
guarantees: context.host.guarantees,
|
|
263
265
|
report: () => { consulted = true; return context.host.report(); },
|
|
264
266
|
subscribe: listener => { consulted = true; return context.host.subscribe(listener); },
|
|
265
267
|
};
|
|
@@ -769,8 +771,8 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
|
|
|
769
771
|
assertNoViolations(found, "The media follows the task");
|
|
770
772
|
}
|
|
771
773
|
/** 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 };
|
|
774
|
+
export function stillHost(report = { online: true }, guarantees = {}) {
|
|
775
|
+
return { guarantees, report: () => report, subscribe: () => () => undefined };
|
|
774
776
|
}
|
|
775
777
|
const usableLogin = (status) => status === "authenticated" || status === "refreshing";
|
|
776
778
|
/**
|
|
@@ -854,14 +856,14 @@ export function assertWrapTimeout(task, mediaEndedAt, observedDeadline, toleranc
|
|
|
854
856
|
/** The session key one scenario derives, or `undefined` where the browser shares nothing. */
|
|
855
857
|
const sessionKeyFor = (scenario) => browserSessionKey(scenario);
|
|
856
858
|
/** Validates whether two task-browser definitions should share one browser session. */
|
|
857
|
-
export function
|
|
859
|
+
export function assertBrowserSessionIsolation(left, right, expectedReuse) {
|
|
858
860
|
const leftKey = sessionKeyFor(left);
|
|
859
861
|
const rightKey = sessionKeyFor(right);
|
|
860
|
-
// A browser that does not
|
|
861
|
-
// Treating "no key" as a match would report
|
|
862
|
+
// A browser that does not share its session has no session key at all, so two of them never share one.
|
|
863
|
+
// Treating "no key" as a match would report sharing nobody asked for.
|
|
862
864
|
const actualReuse = leftKey !== undefined && leftKey === rightKey;
|
|
863
865
|
if (actualReuse !== expectedReuse) {
|
|
864
|
-
throw new Error(`Browser
|
|
866
|
+
throw new Error(`Browser session sharing mismatch: expected ${expectedReuse}, received ${actualReuse} (${String(leftKey)} vs ${String(rightKey)})`);
|
|
865
867
|
}
|
|
866
868
|
}
|
|
867
869
|
/**
|
package/dist/validation.d.ts
CHANGED
|
@@ -43,10 +43,10 @@ export declare function validateTeamRoster(roster: unknown, path?: string, conte
|
|
|
43
43
|
export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
44
44
|
export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
45
45
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* hands the adapter.
|
|
46
|
+
* What a host promises. Presence is the guarantee, so a key declared `false` is refused: a
|
|
47
|
+
* promise withheld is an absent key, never a false one, exactly as a capability is.
|
|
49
48
|
*/
|
|
49
|
+
export declare function validateHostGuarantees(guarantees: unknown, path?: string): ProtocolViolation[];
|
|
50
50
|
export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
|
|
51
51
|
/** The connection methods whose results `validateResult` knows. */
|
|
52
52
|
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 });
|
|
@@ -200,8 +200,8 @@ function validateScheduledActivityInto(activity, path, into) {
|
|
|
200
200
|
into.require(Date.parse(activity.endsAt) >= Date.parse(activity.startsAt), "activity.endsAt.order", `${path}.endsAt`, "endsAt must not precede startsAt");
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
|
-
if (activity.
|
|
204
|
-
validateContactInto(activity.
|
|
203
|
+
if (activity.party !== undefined)
|
|
204
|
+
validateContactInto(activity.party, `${path}.party`, into);
|
|
205
205
|
validateAttributes(activity.attributes, `${path}.attributes`, into);
|
|
206
206
|
}
|
|
207
207
|
// ---------------------------------------------------------------------------
|
|
@@ -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;
|
|
@@ -572,7 +572,7 @@ function validateBrowsers(value, path, into) {
|
|
|
572
572
|
// Reuse and its scheme travel together. A reusing browser with no scheme would otherwise
|
|
573
573
|
// inherit whatever a host happened to default to, which is how two tasks end up sharing a
|
|
574
574
|
// session nobody intended. The guide names the rule for the missing case.
|
|
575
|
-
if (browser.
|
|
575
|
+
if (browser.sharedSession === true) {
|
|
576
576
|
if (browser.isolationScheme === undefined) {
|
|
577
577
|
into.add("task.browser.isolationScheme.required", `${at}.isolationScheme`, `a reusing browser must declare one of: ${ISOLATION_SCHEME_VALUES.join(", ")}`);
|
|
578
578
|
}
|
|
@@ -580,11 +580,11 @@ function validateBrowsers(value, path, into) {
|
|
|
580
580
|
into.require(ISOLATION_SCHEME_VALUES.includes(browser.isolationScheme), "task.browser.isolationScheme", `${at}.isolationScheme`, `an isolation scheme must be one of: ${ISOLATION_SCHEME_VALUES.join(", ")}`);
|
|
581
581
|
}
|
|
582
582
|
}
|
|
583
|
-
else if (browser.
|
|
584
|
-
into.require(browser.isolationScheme === undefined, "task.browser.isolationScheme.unexpected", `${at}.isolationScheme`, "a browser that does not
|
|
583
|
+
else if (browser.sharedSession === false) {
|
|
584
|
+
into.require(browser.isolationScheme === undefined, "task.browser.isolationScheme.unexpected", `${at}.isolationScheme`, "a browser that does not share its session must not declare an isolation scheme");
|
|
585
585
|
}
|
|
586
586
|
else {
|
|
587
|
-
into.add("task.browser.
|
|
587
|
+
into.add("task.browser.sharedSession", `${at}.sharedSession`, "a browser must say whether its session is shared across tasks");
|
|
588
588
|
}
|
|
589
589
|
});
|
|
590
590
|
}
|
|
@@ -745,8 +745,8 @@ function validateTaskInto(task, context, path, into) {
|
|
|
745
745
|
if (task.reference !== undefined) {
|
|
746
746
|
into.filled(task.reference, "task.reference", `${path}.reference`, "a reference must not be empty when present");
|
|
747
747
|
}
|
|
748
|
-
if (task.
|
|
749
|
-
validateContactInto(task.
|
|
748
|
+
if (task.party !== undefined)
|
|
749
|
+
validateContactInto(task.party, `${path}.party`, into, context.levels);
|
|
750
750
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
751
751
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into);
|
|
752
752
|
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into);
|
|
@@ -1117,7 +1117,7 @@ function validateTaskOutcome(value, path, into) {
|
|
|
1117
1117
|
into.add("event.taskEnded.outcome.type", `${path}.type`, `unsupported outcome: ${String(value.type)}`);
|
|
1118
1118
|
}
|
|
1119
1119
|
}
|
|
1120
|
-
function
|
|
1120
|
+
function validateQueueSummary(value, path, into) {
|
|
1121
1121
|
if (!isPlainObject(value)) {
|
|
1122
1122
|
into.add("event.summary.shape", path, "a provider summary must be an object");
|
|
1123
1123
|
return;
|
|
@@ -1238,8 +1238,8 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1238
1238
|
into.require(typeof event.html === "string", "event.announcement.html", `${at}.html`, "html must be a string when present");
|
|
1239
1239
|
}
|
|
1240
1240
|
break;
|
|
1241
|
-
case "
|
|
1242
|
-
|
|
1241
|
+
case "queue-summary":
|
|
1242
|
+
validateQueueSummary(event.summary, `${at}.summary`, into);
|
|
1243
1243
|
break;
|
|
1244
1244
|
case "team-updated":
|
|
1245
1245
|
validateTeamRosterInto(event.team, `${at}.team`, { ...context, levels }, into);
|
|
@@ -1320,7 +1320,7 @@ function validateTeamPoliciesInto(value, path, into, levels) {
|
|
|
1320
1320
|
continue;
|
|
1321
1321
|
}
|
|
1322
1322
|
if (into.oneOf(policy.setting, POLICY_SETTINGS, "team.policy.setting", `${at}.setting`)) {
|
|
1323
|
-
into.require(policy.setting !== "
|
|
1323
|
+
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
1324
|
}
|
|
1325
1325
|
validateResolvedInto(policy, "team.policy", at, levels, into);
|
|
1326
1326
|
into.require(policy.setBy !== "person", "team.policy.setBy", `${at}.setBy`, "a team policy is not set by a person");
|
|
@@ -1361,6 +1361,26 @@ function validateUnavailable(value, rule, path, into) {
|
|
|
1361
1361
|
* check belongs to the host's own tests and to the harness, which validates whatever host a test
|
|
1362
1362
|
* hands the adapter.
|
|
1363
1363
|
*/
|
|
1364
|
+
const HOST_GUARANTEES = membersOf({ browserUrlVisibility: true, personConsent: true });
|
|
1365
|
+
/**
|
|
1366
|
+
* What a host promises. Presence is the guarantee, so a key declared `false` is refused: a
|
|
1367
|
+
* promise withheld is an absent key, never a false one, exactly as a capability is.
|
|
1368
|
+
*/
|
|
1369
|
+
export function validateHostGuarantees(guarantees, path = "host.guarantees") {
|
|
1370
|
+
const into = new Collector();
|
|
1371
|
+
if (!isPlainObject(guarantees)) {
|
|
1372
|
+
into.add("host.guarantees.shape", path, "a host declares its guarantees as an object, empty when it makes none");
|
|
1373
|
+
return into.violations;
|
|
1374
|
+
}
|
|
1375
|
+
for (const [name, declared] of Object.entries(guarantees)) {
|
|
1376
|
+
if (declared === undefined)
|
|
1377
|
+
continue;
|
|
1378
|
+
if (!into.require(HOST_GUARANTEES.includes(name), "host.guarantee.unknown", `${path}.${name}`, `${name} is not a guarantee this contract names: ${HOST_GUARANTEES.join(", ")}`))
|
|
1379
|
+
continue;
|
|
1380
|
+
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");
|
|
1381
|
+
}
|
|
1382
|
+
return into.violations;
|
|
1383
|
+
}
|
|
1364
1384
|
export function validateHostReport(report, path = "host") {
|
|
1365
1385
|
const into = new Collector();
|
|
1366
1386
|
if (!isPlainObject(report)) {
|
|
@@ -1379,7 +1399,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
1379
1399
|
if (!isPlainObject(input)) {
|
|
1380
1400
|
into.add("host.audio.input.shape", at, "audio carries its input");
|
|
1381
1401
|
}
|
|
1382
|
-
else if (input.status === "
|
|
1402
|
+
else if (input.status === "available") {
|
|
1383
1403
|
into.require(typeof input.localAudio === "object" && input.localAudio !== null, "host.audio.input.localAudio", `${at}.localAudio`, "a ready input carries the captured microphone");
|
|
1384
1404
|
into.require(typeof input.flowing === "boolean", "host.audio.input.flowing", `${at}.flowing`, "a ready input says whether audio is flowing through it");
|
|
1385
1405
|
into.require(input.failure === undefined, "host.audio.input.failure.unexpected", `${at}.failure`, "a ready input carries no failure");
|
|
@@ -1392,14 +1412,14 @@ export function validateHostReport(report, path = "host") {
|
|
|
1392
1412
|
into.require(input.flowing === undefined, "host.audio.input.flowing.unexpected", `${at}.flowing`, "an unavailable input has nothing to flow");
|
|
1393
1413
|
}
|
|
1394
1414
|
else {
|
|
1395
|
-
into.add("host.audio.input.status", `${at}.status`, `an input is
|
|
1415
|
+
into.add("host.audio.input.status", `${at}.status`, `an input is available or unavailable, not ${String(input.status)}`);
|
|
1396
1416
|
}
|
|
1397
1417
|
const output = report.audio.output;
|
|
1398
1418
|
const out = `${path}.audio.output`;
|
|
1399
1419
|
if (!isPlainObject(output)) {
|
|
1400
1420
|
into.add("host.audio.output.shape", out, "audio carries its output");
|
|
1401
1421
|
}
|
|
1402
|
-
else if (output.status === "
|
|
1422
|
+
else if (output.status === "available") {
|
|
1403
1423
|
into.require(output.failure === undefined, "host.audio.output.failure.unexpected", `${out}.failure`, "a ready output carries no failure");
|
|
1404
1424
|
into.require(output.reason === undefined, "host.audio.output.reason.unexpected", `${out}.reason`, "a ready output has no reason to be unavailable");
|
|
1405
1425
|
}
|
|
@@ -1408,7 +1428,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
1408
1428
|
validateUnavailable(output, "host.audio.output", out, into);
|
|
1409
1429
|
}
|
|
1410
1430
|
else {
|
|
1411
|
-
into.add("host.audio.output.status", `${out}.status`, `an output is
|
|
1431
|
+
into.add("host.audio.output.status", `${out}.status`, `an output is available or unavailable, not ${String(output.status)}`);
|
|
1412
1432
|
}
|
|
1413
1433
|
return into.violations;
|
|
1414
1434
|
}
|
|
@@ -1417,7 +1437,7 @@ export function validateHostReport(report, path = "host") {
|
|
|
1417
1437
|
const RESULT_STATUSES = {
|
|
1418
1438
|
execute: { success: "applied", failure: "failed" },
|
|
1419
1439
|
dial: { success: "dialled", failure: "failed" },
|
|
1420
|
-
setCapacity: { success: "
|
|
1440
|
+
setCapacity: { success: "applied", failure: "failed" },
|
|
1421
1441
|
requestBreak: { success: "requested", failure: "failed" },
|
|
1422
1442
|
commitBreak: { success: "committed", failure: "failed" },
|
|
1423
1443
|
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
|
|
|
@@ -393,7 +399,7 @@ type ScheduledActivity = {
|
|
|
393
399
|
title: string;
|
|
394
400
|
startsAt: IsoTimestamp;
|
|
395
401
|
endsAt?: IsoTimestamp;
|
|
396
|
-
|
|
402
|
+
party?: Contact;
|
|
397
403
|
attributes?: Attribute[];
|
|
398
404
|
};
|
|
399
405
|
|
|
@@ -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
|
|
|
@@ -492,8 +498,8 @@ type TaskBrowser = Browser & {
|
|
|
492
498
|
purpose: string;
|
|
493
499
|
urlVisibility?: UrlVisibility;
|
|
494
500
|
} & (
|
|
495
|
-
| {
|
|
496
|
-
| {
|
|
501
|
+
| { sharedSession: false; isolationScheme?: never }
|
|
502
|
+
| { sharedSession: true; isolationScheme: BrowserIsolationScheme }
|
|
497
503
|
);
|
|
498
504
|
|
|
499
505
|
type PersonalBrowser = Browser;
|
|
@@ -507,7 +513,7 @@ type BrowserSessionKeyInput = {
|
|
|
507
513
|
```
|
|
508
514
|
|
|
509
515
|
That union is what makes a reusing browser with no scheme fail to compile rather than inherit a
|
|
510
|
-
default — see **Choosing
|
|
516
|
+
default — see **Choosing an isolation scheme**.
|
|
511
517
|
|
|
512
518
|
### Task
|
|
513
519
|
|
|
@@ -529,7 +535,7 @@ type TaskAttributeBase = {
|
|
|
529
535
|
|
|
530
536
|
type TaskAttribute = TaskAttributeBase & (
|
|
531
537
|
| { type: "text"; value: string }
|
|
532
|
-
| { type: "contact";
|
|
538
|
+
| { type: "contact"; party: Contact }
|
|
533
539
|
| { type: "timestamp"; at: IsoTimestamp }
|
|
534
540
|
);
|
|
535
541
|
|
|
@@ -596,7 +602,7 @@ type Task<C extends Channel = Channel> = {
|
|
|
596
602
|
taskType: string;
|
|
597
603
|
capabilities: TaskCapabilities<C>;
|
|
598
604
|
browsers: TaskBrowser[];
|
|
599
|
-
|
|
605
|
+
party?: Contact;
|
|
600
606
|
phase: TaskPhase;
|
|
601
607
|
reference?: string;
|
|
602
608
|
attributes?: TaskAttribute[];
|
|
@@ -609,8 +615,8 @@ type Task<C extends Channel = Channel> = {
|
|
|
609
615
|
|
|
610
616
|
type AcceptanceMode =
|
|
611
617
|
| "no-preference"
|
|
612
|
-
| "
|
|
613
|
-
| "
|
|
618
|
+
| "consent"
|
|
619
|
+
| "automatic";
|
|
614
620
|
|
|
615
621
|
type TaskOutcome =
|
|
616
622
|
| { type: "completed"; by: "agent" | "provider" }
|
|
@@ -747,8 +753,8 @@ controls — is content, and is never locked.
|
|
|
747
753
|
|
|
748
754
|
**A lead sets the team's policy from their roster.** A login that declares
|
|
749
755
|
`capabilities.team.policyControl` may `executeTeamPolicy({ type: "set", capability, setting })`
|
|
750
|
-
with `on`, `off`, or `
|
|
751
|
-
and skills may be `
|
|
756
|
+
with `on`, `off`, or `person`, for any task control, `dial`, or a skill — and only `hold`, `mute`
|
|
757
|
+
and skills may be `person`; callback and new call are the team's, on or off, within what the queue
|
|
752
758
|
allows. The roster carries `policies` for such a login: every policy as it stands, who set it, and
|
|
753
759
|
`lockedBy` where a level above the team made it theirs to keep, which the lead sees and cannot
|
|
754
760
|
change — `executeTeamPolicy` on it answers `failed` with `omni.capability-not-enabled`.
|
|
@@ -856,7 +862,7 @@ type PolicyKey =
|
|
|
856
862
|
| "dial"
|
|
857
863
|
| `skill:${string}`;
|
|
858
864
|
|
|
859
|
-
type TeamPolicySetting = "on" | "off" | "
|
|
865
|
+
type TeamPolicySetting = "on" | "off" | "person";
|
|
860
866
|
|
|
861
867
|
type TeamPolicy = Resolved & {
|
|
862
868
|
setting: TeamPolicySetting;
|
|
@@ -917,7 +923,7 @@ type OpenMediaRequest = {
|
|
|
917
923
|
```ts
|
|
918
924
|
type SummaryMetric = { id: string; label: string; value: string };
|
|
919
925
|
|
|
920
|
-
type
|
|
926
|
+
type QueueSummary = {
|
|
921
927
|
title: string;
|
|
922
928
|
subtitle?: string;
|
|
923
929
|
waitingCount: number;
|
|
@@ -944,7 +950,7 @@ type ProviderEvent =
|
|
|
944
950
|
| { type: "task-media-ended"; taskId: TaskId }
|
|
945
951
|
| { type: "task-ended"; taskId: TaskId; outcome: TaskOutcome }
|
|
946
952
|
| { type: "announcement"; text: string; html?: string; announcedAt: IsoTimestamp; expiresAt?: IsoTimestamp }
|
|
947
|
-
| { type: "
|
|
953
|
+
| { type: "queue-summary"; summary: QueueSummary }
|
|
948
954
|
| { type: "team-updated"; team: TeamRoster }
|
|
949
955
|
| { type: "contacts-updated"; contacts: Contact[] }
|
|
950
956
|
| { type: "calendar-updated"; scheduledActivities: ScheduledActivity[] };
|
|
@@ -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
|
|
@@ -1526,7 +1532,7 @@ changes.
|
|
|
1526
1532
|
| `title` | Required agent-facing activity title. |
|
|
1527
1533
|
| `startsAt` | Required RFC-3339 start time with an explicit timezone. |
|
|
1528
1534
|
| `endsAt` | Optional RFC-3339 end time with an explicit timezone. |
|
|
1529
|
-
| `
|
|
1535
|
+
| `party` | The person the activity reaches — a callback's customer — as a `Contact`. Optional. |
|
|
1530
1536
|
| `attributes` | Optional ordered `Attribute` entries. Keys must be non-empty. |
|
|
1531
1537
|
|
|
1532
1538
|
**There is no `type` field**, for the reason there is none on `Contact`: an open category is
|
|
@@ -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
|
|
@@ -1979,24 +1985,27 @@ provider includes an acceptance directive with each allocation:
|
|
|
1979
1985
|
| Directive | Contract |
|
|
1980
1986
|
| --- | --- |
|
|
1981
1987
|
| `no-preference` | The provider leaves acceptance to Omni; with `autoAcceptTasks: true`, Omni accepts automatically. |
|
|
1982
|
-
| `
|
|
1983
|
-
| `
|
|
1988
|
+
| `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. |
|
|
1989
|
+
| `automatic` | Omni accepts immediately without agent interaction. |
|
|
1984
1990
|
|
|
1985
1991
|
When Omni sent `autoAcceptTasks: false`, the provider omits `acceptanceMode` and every task
|
|
1986
|
-
requires agent acceptance.
|
|
1992
|
+
requires agent acceptance. The two are never confused on the wire: `consent` is always the
|
|
1993
|
+
provider's requirement, stated on a wire where Omni was willing to accept for the agent; Omni's own
|
|
1994
|
+
no-auto-accept policy puts no word on the wire at all — the field is absent, and the **Accept**
|
|
1995
|
+
press is Omni's doing, not the provider's.
|
|
1987
1996
|
|
|
1988
1997
|
**An absent value means `true`**, as `readyOnLogin` does, because an agent who has signed in and
|
|
1989
1998
|
gone ready is telling the deployment they are working. Requiring a press before every contact is
|
|
1990
1999
|
the exception a provisioning file asks for, not the state it falls into when a flag is missing.
|
|
1991
2000
|
|
|
1992
2001
|
Nothing is given away by that default. `acceptanceMode` is the provider's own control and outranks
|
|
1993
|
-
it: `
|
|
2002
|
+
it: `consent` puts the decision back in the agent's hands for any task where it
|
|
1994
2003
|
belongs, whatever the host was configured with.
|
|
1995
2004
|
|
|
1996
2005
|
An automatically accepted task still arrives through `task-offered`.
|
|
1997
2006
|
|
|
1998
2007
|
Agent-initiated work arrives through `task-offered` with
|
|
1999
|
-
`acceptanceMode: "
|
|
2008
|
+
`acceptanceMode: "automatic"`.
|
|
2000
2009
|
|
|
2001
2010
|
### Pending
|
|
2002
2011
|
|
|
@@ -2011,7 +2020,7 @@ declare const task: Task;
|
|
|
2011
2020
|
const allocation = {
|
|
2012
2021
|
type: "task-offered",
|
|
2013
2022
|
task,
|
|
2014
|
-
acceptanceMode: "
|
|
2023
|
+
acceptanceMode: "consent",
|
|
2015
2024
|
allocationExpiresAt: "2026-08-25T10:41:07.000Z",
|
|
2016
2025
|
preparationEndsAt: "2026-08-25T10:40:37.000Z",
|
|
2017
2026
|
} satisfies Extract<ProviderEvent, { type: "task-offered" }>;
|
|
@@ -2021,7 +2030,7 @@ The rule the phase exists to express: **nothing is acquired on the agent's behal
|
|
|
2021
2030
|
is pending.** A host that carries media must not open the microphone until the task is
|
|
2022
2031
|
accepted. Omni does not open the task's browsers either — a task that rings out costs nothing.
|
|
2023
2032
|
|
|
2024
|
-
When
|
|
2033
|
+
When consent is required, Omni offers the agent an **Accept** control. The call is the
|
|
2025
2034
|
medium the task arrives on, not a separate decision.
|
|
2026
2035
|
|
|
2027
2036
|
**Once a task is accepted, the call that comes with it is answered.** Omni has no discretion
|
|
@@ -2071,7 +2080,7 @@ time. Runtime conformance checks also require the task channel to match its prov
|
|
|
2071
2080
|
| `taskType` | Required provider-defined source or category of work, such as a voice `Queue Name`, `Mailbox Folder`, `Chat Source`, `Support`, `Billing`, or `Returns`. |
|
|
2072
2081
|
| `capabilities` | Controls and workspace features available for this specific task. |
|
|
2073
2082
|
| `browsers` | Named browser definitions for the task workspace: at least one when the task declares the `browsers` capability, empty when it does not. |
|
|
2074
|
-
| `
|
|
2083
|
+
| `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
2084
|
| `phase` | Current canonical task phase: `pending`, `confirmed`, `preparing`, `in-progress`, `paused`, or `completing`. |
|
|
2076
2085
|
| `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`**. |
|
|
2077
2086
|
| `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
|
|
@@ -2091,7 +2100,7 @@ const attributes: TaskAttribute[] = [
|
|
|
2091
2100
|
key: "related-contact",
|
|
2092
2101
|
label: "Related contact",
|
|
2093
2102
|
type: "contact",
|
|
2094
|
-
|
|
2103
|
+
party: { name: "Asha Rao", number: "+919876543210" },
|
|
2095
2104
|
},
|
|
2096
2105
|
{
|
|
2097
2106
|
key: "answered",
|
|
@@ -2329,14 +2338,14 @@ Each `TaskBrowser` defines one named browser in the task workspace.
|
|
|
2329
2338
|
| `name` | Agent-facing tab label, unique within the task, and an input to schemes containing `TAB_NAME`. |
|
|
2330
2339
|
| `purpose` | Human-readable explanation of the browser's role. |
|
|
2331
2340
|
| `url` | Initial URL. Must use `http:` or `https:`; see below. Later navigation comes from Chromium. |
|
|
2332
|
-
| `
|
|
2333
|
-
| `isolationScheme` | **Required when `
|
|
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;
|
|
2341
|
+
| `sharedSession` | Required. `false` creates a task-specific browser session. |
|
|
2342
|
+
| `isolationScheme` | **Required when `sharedSession` is `true`**, and rejected when it is `false`. There is no default: see below. |
|
|
2343
|
+
| `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
2344
|
|
|
2336
|
-
##### Choosing
|
|
2345
|
+
##### Choosing an isolation scheme
|
|
2337
2346
|
|
|
2338
2347
|
Every scheme is supported and the provider picks the one its deployment needs. There is no
|
|
2339
|
-
default, and a `
|
|
2348
|
+
default, and a `sharedSession: true` browser that declares none is invalid — the type will not compile
|
|
2340
2349
|
it and `validateSnapshot` reports `task.browser.isolationScheme.required`.
|
|
2341
2350
|
|
|
2342
2351
|
That is deliberate. Sharing a signed-in session decides **who else may see those credentials**,
|
|
@@ -2358,7 +2367,7 @@ page rather than following a disallowed URL, and `isAllowedBrowserUrl()` is the
|
|
|
2358
2367
|
|
|
2359
2368
|
##### Reuse and isolation
|
|
2360
2369
|
|
|
2361
|
-
With `
|
|
2370
|
+
With `sharedSession: true`, definitions producing the same isolation key share one **storage profile**:
|
|
2362
2371
|
cookies, local storage, session storage, permissions, and cached credentials. Different keys are
|
|
2363
2372
|
isolated from one another.
|
|
2364
2373
|
|
|
@@ -2382,7 +2391,7 @@ browsers: [
|
|
|
2382
2391
|
name: "CRM",
|
|
2383
2392
|
purpose: "Contact record",
|
|
2384
2393
|
url: "https://crm.example.com/contact/42",
|
|
2385
|
-
|
|
2394
|
+
sharedSession: true,
|
|
2386
2395
|
isolationScheme: BROWSER_ISOLATION_SCHEMES.PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME,
|
|
2387
2396
|
}
|
|
2388
2397
|
]
|
|
@@ -2464,7 +2473,7 @@ capabilities: {
|
|
|
2464
2473
|
| Field | Contract |
|
|
2465
2474
|
| --- | --- |
|
|
2466
2475
|
| `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 `
|
|
2476
|
+
| `notes` | `required`, `optional`, or `none`; controls the free-text field beside the code. |
|
|
2468
2477
|
| `codes` | Codes Omni offers. `id` values are non-empty and unique; Omni sends the chosen `id` as `TaskCommand.complete.disposition`. |
|
|
2469
2478
|
|
|
2470
2479
|
With `dispositions: true` Omni shows a Complete control and sends `complete` with no code, because
|
|
@@ -2693,7 +2702,7 @@ everywhere; success is not.
|
|
|
2693
2702
|
|
|
2694
2703
|
| Method | Succeeded |
|
|
2695
2704
|
| --- | --- |
|
|
2696
|
-
| `setCapacity` | `
|
|
2705
|
+
| `setCapacity` | `applied` |
|
|
2697
2706
|
| `requestBreak` | `requested` |
|
|
2698
2707
|
| `commitBreak` | `committed` |
|
|
2699
2708
|
| `cancelBreak` | `cancelled` |
|
|
@@ -3080,7 +3089,7 @@ executeTeamConsult({ command: { type: "decline", requestId: "req-7", reason: "In
|
|
|
3080
3089
|
```
|
|
3081
3090
|
|
|
3082
3091
|
**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 `
|
|
3092
|
+
same task id, arriving on the lead's connection as `task-offered` with `automatic`
|
|
3084
3093
|
-- the way a call an agent placed themselves arrives -- and carrying `assisting`. The agent's task
|
|
3085
3094
|
moves to `lead: { stage: "joined", leadId }`. A join is the lead's own act, so capacity does not
|
|
3086
3095
|
trigger it; but from then on it is an outstanding task the provider counts against the lead's
|
|
@@ -3190,12 +3199,27 @@ the microphone once as the voice connection opens, so the permission prompt land
|
|
|
3190
3199
|
is signing in rather than over a contact, prompts, retries on the agent's request, tells the agent
|
|
3191
3200
|
what failed, and reports. It never decides for the adapter what a missing microphone means.
|
|
3192
3201
|
|
|
3202
|
+
**The host also guarantees, and a promise the provider cannot see is not one it can rely on.**
|
|
3203
|
+
Two of this contract's obligations fall on the host rather than the provider — honouring a task
|
|
3204
|
+
browser's `urlVisibility`, and taking a `consent` offer only on the person's own press — and more
|
|
3205
|
+
than one desk speaks this contract. So `ConnectContext.host.guarantees` says which promises the
|
|
3206
|
+
connected host makes, declared once per connection, presence being the guarantee exactly as it is
|
|
3207
|
+
the permission everywhere else:
|
|
3208
|
+
|
|
3209
|
+
| Guarantee | Contract |
|
|
3210
|
+
| --- | --- |
|
|
3211
|
+
| `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. |
|
|
3212
|
+
| `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. |
|
|
3213
|
+
|
|
3214
|
+
A guarantee the host does not make is an absent key, never `false` — `validateHostGuarantees`
|
|
3215
|
+
refuses a false one, as it refuses a name this contract does not list.
|
|
3216
|
+
|
|
3193
3217
|
| Field | Contract |
|
|
3194
3218
|
| --- | --- |
|
|
3195
3219
|
| `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
3220
|
| `audio` | Present on a voice connection, absent where there is no audio. |
|
|
3197
|
-
| `audio.input` | `
|
|
3198
|
-
| `audio.output` | `
|
|
3221
|
+
| `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. |
|
|
3222
|
+
| `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
3223
|
|
|
3200
3224
|
Omni republishes the report whenever it changes — a permission granted late, a headset unplugged,
|
|
3201
3225
|
a network gone — and it publishes a state, not a flicker: a change that resolves within moments is
|
|
@@ -3580,7 +3604,7 @@ Publishes an agent-facing message. `text` is always required and is the accessib
|
|
|
3580
3604
|
Optional HTML is sanitized by Omni. `announcedAt` and optional `expiresAt` are RFC-3339 times with
|
|
3581
3605
|
explicit timezones.
|
|
3582
3606
|
|
|
3583
|
-
### `
|
|
3607
|
+
### `queue-summary`
|
|
3584
3608
|
|
|
3585
3609
|
Publishes the provider's current dashboard contribution. Omni combines only the latest summary from
|
|
3586
3610
|
each connected provider.
|
|
@@ -3647,7 +3671,8 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
3647
3671
|
| `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
|
|
3648
3672
|
| `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
|
|
3649
3673
|
| `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
|
|
3650
|
-
| `
|
|
3674
|
+
| `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. |
|
|
3675
|
+
| `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
3676
|
| `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
3677
|
| `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
3678
|
|
|
@@ -3742,7 +3767,7 @@ cannot be established from TypeScript structure alone.
|
|
|
3742
3767
|
| `assertBreakBeginsAfterTask(steps)` | A break asked for on a task is committed as `starting-after-task` while work remains and reaches `in-effect` only once nothing is outstanding — never beside a task, never later than the step that has none. |
|
|
3743
3768
|
| `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
|
|
3744
3769
|
| `assertWrapTimeout(task, mediaEndedAt, deadline, toleranceMs?)` | The wrap deadline equals media end plus the task allowance, within a tolerance that defaults to 1000ms; a task with no allowance has no deadline, and one observed is the violation. |
|
|
3745
|
-
| `
|
|
3770
|
+
| `assertBrowserSessionIsolation(left, right, expected)` | Browser reuse follows only the declared isolation scheme. |
|
|
3746
3771
|
| `assertNoBrowserSessionKeyCollisions(scenarios)` | No two distinct scenarios derive the same session key. Feed it adversarial names. |
|
|
3747
3772
|
|
|
3748
3773
|
Adapters should run the relevant scenarios against deterministic test state before publishing.
|