@xema/omni-protocol 0.1.24 → 0.1.26
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 -1
- package/dist/index.d.ts +69 -63
- package/dist/index.js +5 -5
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +31 -23
- package/dist/validation.d.ts +10 -10
- package/dist/validation.js +93 -90
- package/guide.md +165 -132
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ clean result is read for what it covers and not for the whole contract; `assertR
|
|
|
65
65
|
subjects)` is the paired assertion.
|
|
66
66
|
|
|
67
67
|
```ts
|
|
68
|
-
const context = { protocolVersion: OMNI_PROTOCOL_VERSION,
|
|
68
|
+
const context = { protocolVersion: OMNI_PROTOCOL_VERSION, loginId: "session-1", host: stillHost(report) };
|
|
69
69
|
const result = await exerciseAdapter(adapter, context, { collectOnly: true });
|
|
70
70
|
expect(result.violations).toEqual([]);
|
|
71
71
|
expect(result.disconnectWasClean).toBe(true);
|
package/dist/index.d.ts
CHANGED
|
@@ -54,19 +54,19 @@ export interface ProtocolViolation {
|
|
|
54
54
|
message: string;
|
|
55
55
|
}
|
|
56
56
|
export type AuthenticationMethod = "browser-sso" | "credentials";
|
|
57
|
-
export interface
|
|
57
|
+
export interface BrowserAccess {
|
|
58
58
|
/** The decision when no list entry matches. */
|
|
59
59
|
mode: "allow-all" | "block-all";
|
|
60
60
|
allowList?: string[];
|
|
61
61
|
blockList?: string[];
|
|
62
62
|
}
|
|
63
63
|
export interface PersonalBrowserCapability {
|
|
64
|
-
access:
|
|
64
|
+
access: BrowserAccess;
|
|
65
65
|
accessPolicyScope?: "initial-url" | "all-navigation";
|
|
66
66
|
}
|
|
67
|
-
export type
|
|
67
|
+
export type DialDestinations = "contacts-only" | "any-number";
|
|
68
68
|
export interface DialCapability {
|
|
69
|
-
|
|
69
|
+
destinations: DialDestinations;
|
|
70
70
|
}
|
|
71
71
|
/** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
|
|
72
72
|
export declare const IDLE_CAPABILITIES: readonly ["dial", "personalBrowser", "calendar", "contacts"];
|
|
@@ -111,8 +111,8 @@ export interface Manifest<C extends Channel = Channel> {
|
|
|
111
111
|
phaseLabels?: TaskPhaseLabels;
|
|
112
112
|
/** Keyed by `taskType`. An entry replaces the channel default outright rather than merging. */
|
|
113
113
|
taskTypePresentation?: Record<string, TaskTypePresentation>;
|
|
114
|
-
/** The organisation's whole ladder, stated outright, `person` included. Omitted for the typical four, `
|
|
115
|
-
|
|
114
|
+
/** The organisation's whole ladder, stated outright, `person` included. Omitted for the typical four, `DEFAULT_LEVELS`. */
|
|
115
|
+
orgLevels?: LevelDeclaration[];
|
|
116
116
|
}
|
|
117
117
|
export interface SecretStore {
|
|
118
118
|
get(key: string): Promise<string | undefined>;
|
|
@@ -121,8 +121,8 @@ export interface SecretStore {
|
|
|
121
121
|
}
|
|
122
122
|
export interface AuthenticationContext {
|
|
123
123
|
protocolVersion: number;
|
|
124
|
-
/** Omni's identity for this login. The same value arrives later as `ConnectContext.
|
|
125
|
-
|
|
124
|
+
/** Omni's identity for this login. The same value arrives later as `ConnectContext.loginId`. */
|
|
125
|
+
loginId: string;
|
|
126
126
|
/** Scoped to this provider's manifest id. */
|
|
127
127
|
secrets: SecretStore;
|
|
128
128
|
signal?: AbortSignal;
|
|
@@ -155,7 +155,7 @@ export interface TeamCapabilities {
|
|
|
155
155
|
* of who the agent is on this provider: the provider knows the roles and says so at sign-in.
|
|
156
156
|
* Current as of the latest `authenticated` state, never fixed for the login.
|
|
157
157
|
*/
|
|
158
|
-
export interface
|
|
158
|
+
export interface UserCapabilities {
|
|
159
159
|
/** This login may request a break. Requires the four break methods. */
|
|
160
160
|
breaks?: true;
|
|
161
161
|
/** The choices the team left to this person, with where each stands. Omitted when there are none. Requires `setPreference`. */
|
|
@@ -175,12 +175,12 @@ export type AuthenticationState = {
|
|
|
175
175
|
} | {
|
|
176
176
|
status: "authenticated";
|
|
177
177
|
identity: User;
|
|
178
|
-
capabilities:
|
|
178
|
+
capabilities: UserCapabilities;
|
|
179
179
|
expiresAt?: IsoTimestamp;
|
|
180
180
|
} | {
|
|
181
181
|
status: "refreshing";
|
|
182
182
|
identity: User;
|
|
183
|
-
capabilities:
|
|
183
|
+
capabilities: UserCapabilities;
|
|
184
184
|
} | {
|
|
185
185
|
status: "expired";
|
|
186
186
|
identity?: User;
|
|
@@ -230,7 +230,7 @@ export type CompleteAuthenticationRequest = {
|
|
|
230
230
|
export type CompleteAuthenticationResult = {
|
|
231
231
|
status: "authenticated";
|
|
232
232
|
identity: User;
|
|
233
|
-
capabilities:
|
|
233
|
+
capabilities: UserCapabilities;
|
|
234
234
|
expiresAt?: IsoTimestamp;
|
|
235
235
|
} | {
|
|
236
236
|
status: "rejected";
|
|
@@ -295,10 +295,6 @@ export type HostAudioOutput = {
|
|
|
295
295
|
/** What Omni's own chrome shows of a task browser's URL: nothing, the domain, or all of it. */
|
|
296
296
|
export type UrlVisibility = "full" | "domain" | "hidden";
|
|
297
297
|
export interface HostReport {
|
|
298
|
-
/** What the agent can see of a task browser's URL in Omni's chrome. Task browsers only; the personal browser is the agent's. */
|
|
299
|
-
browsers: {
|
|
300
|
-
urlVisibility: UrlVisibility;
|
|
301
|
-
};
|
|
302
298
|
/** Whether the host has a network interface up. Not a claim that anything is reachable: the adapter knows its own platform's reachability better than the host does. */
|
|
303
299
|
online: boolean;
|
|
304
300
|
audio?: {
|
|
@@ -314,7 +310,7 @@ export interface Host {
|
|
|
314
310
|
export interface ConnectContext {
|
|
315
311
|
protocolVersion: number;
|
|
316
312
|
/** The session that authenticated this connection. */
|
|
317
|
-
|
|
313
|
+
loginId: string;
|
|
318
314
|
/** Omni-side policy: whether the agent's tasks are accepted without asking them. */
|
|
319
315
|
autoAcceptTasks?: boolean;
|
|
320
316
|
/**
|
|
@@ -325,13 +321,13 @@ export interface ConnectContext {
|
|
|
325
321
|
signal?: AbortSignal;
|
|
326
322
|
log?: (entry: unknown) => void;
|
|
327
323
|
}
|
|
328
|
-
export type
|
|
324
|
+
export type TransportStatus = "connecting" | "active" | "error";
|
|
329
325
|
/**
|
|
330
326
|
* What revives a connection that reported `error`: `reconnect` -- the login is good, dispose this
|
|
331
327
|
* connection and call `connect()` again -- or `reauthenticate` -- run the authentication flow
|
|
332
328
|
* first. The adapter knows which; the host acts on its word.
|
|
333
329
|
*/
|
|
334
|
-
export type
|
|
330
|
+
export type TransportRecovery = "reconnect" | "reauthenticate";
|
|
335
331
|
/** Every field is optional: a provider sends what it knows and omits what it does not. */
|
|
336
332
|
export interface Contact {
|
|
337
333
|
name?: string;
|
|
@@ -354,7 +350,7 @@ export interface DispositionCode {
|
|
|
354
350
|
label: string;
|
|
355
351
|
group?: string;
|
|
356
352
|
}
|
|
357
|
-
export interface
|
|
353
|
+
export interface DispositionRules {
|
|
358
354
|
required?: boolean;
|
|
359
355
|
notes?: "required" | "optional" | "hidden";
|
|
360
356
|
codes?: DispositionCode[];
|
|
@@ -373,7 +369,7 @@ export interface DestinationDirectory {
|
|
|
373
369
|
export interface CustomCapability {
|
|
374
370
|
id: string;
|
|
375
371
|
ui: {
|
|
376
|
-
|
|
372
|
+
control: "button" | "toggle" | "menu-item";
|
|
377
373
|
label: string;
|
|
378
374
|
placement: "primary" | "secondary" | "overflow";
|
|
379
375
|
/** Where the control's work renders: inline in the workspace, or as a page of its own. Inline when absent. */
|
|
@@ -386,7 +382,7 @@ export interface CustomCapability {
|
|
|
386
382
|
}
|
|
387
383
|
export interface SharedTaskCapabilities {
|
|
388
384
|
browsers?: true;
|
|
389
|
-
dispositions?: true |
|
|
385
|
+
dispositions?: true | DispositionRules;
|
|
390
386
|
custom?: CustomCapability[];
|
|
391
387
|
}
|
|
392
388
|
/**
|
|
@@ -429,24 +425,32 @@ export declare const BROWSER_ISOLATION_SCHEMES: {
|
|
|
429
425
|
readonly TASK_TYPE_NAME__TAB_NAME: "TaskTypeName.TabName";
|
|
430
426
|
};
|
|
431
427
|
export type BrowserIsolationScheme = (typeof BROWSER_ISOLATION_SCHEMES)[keyof typeof BROWSER_ISOLATION_SCHEMES];
|
|
432
|
-
|
|
428
|
+
/** One tab, in either workspace. */
|
|
429
|
+
export interface Browser {
|
|
433
430
|
id: string;
|
|
434
431
|
name: string;
|
|
435
|
-
purpose: string;
|
|
436
432
|
/** `http:` or `https:` only. */
|
|
437
433
|
url: string;
|
|
438
434
|
}
|
|
439
435
|
/**
|
|
440
|
-
*
|
|
441
|
-
*
|
|
436
|
+
* A tab the task brought: fixed at the task's definition -- count and details -- with why it is
|
|
437
|
+
* there, whether its session is shared across tasks, and whether the agent may read its URL. The
|
|
438
|
+
* union is what makes a reusing browser with no scheme fail to compile rather than inherit a
|
|
439
|
+
* default -- which is how two tasks end up sharing a session nobody intended.
|
|
442
440
|
*/
|
|
443
|
-
export type TaskBrowser =
|
|
441
|
+
export type TaskBrowser = Browser & {
|
|
442
|
+
purpose: string;
|
|
443
|
+
/** 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
|
+
urlVisibility?: UrlVisibility;
|
|
445
|
+
} & ({
|
|
444
446
|
reuse: false;
|
|
445
447
|
isolationScheme?: never;
|
|
446
448
|
} | {
|
|
447
449
|
reuse: true;
|
|
448
450
|
isolationScheme: BrowserIsolationScheme;
|
|
449
451
|
});
|
|
452
|
+
/** A tab the agent opened in the personal workspace: theirs, as many as they like, and never on the wire. */
|
|
453
|
+
export type PersonalBrowser = Browser;
|
|
450
454
|
export declare const ALLOWED_BROWSER_URL_SCHEMES: readonly ["http:", "https:"];
|
|
451
455
|
export declare function isAllowedBrowserUrl(url: string): boolean;
|
|
452
456
|
export type TaskPhase =
|
|
@@ -500,10 +504,10 @@ export interface TaskHandlingStep {
|
|
|
500
504
|
*/
|
|
501
505
|
export type TaskCompletion = {
|
|
502
506
|
completionMode: "agent-command";
|
|
503
|
-
|
|
507
|
+
wrapAllowance?: DurationSeconds;
|
|
504
508
|
} | {
|
|
505
509
|
completionMode: "provider-automatic";
|
|
506
|
-
|
|
510
|
+
wrapAllowance: DurationSeconds;
|
|
507
511
|
};
|
|
508
512
|
/**
|
|
509
513
|
* A consultation in progress on a task: who is being consulted, and since when where the provider
|
|
@@ -520,7 +524,7 @@ export interface TaskConsultation {
|
|
|
520
524
|
* `requested` while nobody has joined; `joined`, with `leadId`, once somebody has.
|
|
521
525
|
*/
|
|
522
526
|
export interface TaskLead {
|
|
523
|
-
|
|
527
|
+
stage: "requested" | "joined";
|
|
524
528
|
leadId?: UserId;
|
|
525
529
|
note?: string;
|
|
526
530
|
since: IsoTimestamp;
|
|
@@ -535,21 +539,21 @@ export interface TaskAssisting {
|
|
|
535
539
|
since: IsoTimestamp;
|
|
536
540
|
}
|
|
537
541
|
/**
|
|
538
|
-
* A
|
|
539
|
-
* a typical organisation has, `
|
|
540
|
-
* never describes the chain: which
|
|
542
|
+
* A level of the organisation's structure, by the id its manifest declares -- or one of the four
|
|
543
|
+
* a typical organisation has, `DEFAULT_LEVELS`, when the manifest declares no ladder. The protocol
|
|
544
|
+
* never describes the chain: which levels a person passes through is the structure's to know.
|
|
541
545
|
*/
|
|
542
|
-
export type
|
|
543
|
-
/** A
|
|
544
|
-
export interface
|
|
545
|
-
id:
|
|
546
|
+
export type Level = string;
|
|
547
|
+
/** A level the structure has, with the label a desk shows for "who decided". */
|
|
548
|
+
export interface LevelDeclaration {
|
|
549
|
+
id: Level;
|
|
546
550
|
label: string;
|
|
547
551
|
}
|
|
548
552
|
/**
|
|
549
|
-
* The
|
|
550
|
-
* `
|
|
553
|
+
* The levels a typical organisation has: the ladder in force when a manifest declares no
|
|
554
|
+
* `orgLevels`. A manifest that declares any states its whole ladder outright.
|
|
551
555
|
*/
|
|
552
|
-
export declare const
|
|
556
|
+
export declare const DEFAULT_LEVELS: readonly [{
|
|
553
557
|
readonly id: "org";
|
|
554
558
|
readonly label: "Your organisation";
|
|
555
559
|
}, {
|
|
@@ -563,14 +567,14 @@ export declare const DEFAULT_TIERS: readonly [{
|
|
|
563
567
|
readonly label: "You";
|
|
564
568
|
}];
|
|
565
569
|
/** The ladder in force for a manifest: exactly what it declares, or the defaults when it declares none. */
|
|
566
|
-
export declare function
|
|
570
|
+
export declare function effectiveLevels(declared: readonly LevelDeclaration[] | undefined): LevelDeclaration[];
|
|
567
571
|
/**
|
|
568
|
-
* Something the queue could allow, locked above the person: the
|
|
569
|
-
* and why if they said. `person` never locks their own value, and the queue is not a
|
|
572
|
+
* Something the queue could allow, locked above the person: the level that made it unchangeable,
|
|
573
|
+
* and why if they said. `person` never locks their own value, and the queue is not a level --
|
|
570
574
|
* what the queue does not allow at all is simply absent.
|
|
571
575
|
*/
|
|
572
576
|
export interface Locked {
|
|
573
|
-
lockedBy:
|
|
577
|
+
lockedBy: Level;
|
|
574
578
|
reason?: string;
|
|
575
579
|
}
|
|
576
580
|
/**
|
|
@@ -782,7 +786,7 @@ export interface BreakReason {
|
|
|
782
786
|
label: string;
|
|
783
787
|
group?: string;
|
|
784
788
|
kind?: BreakKind;
|
|
785
|
-
/** Survives `
|
|
789
|
+
/** Survives `mayAsk: false`: a mandatory rest is not something a busy hour can cancel. */
|
|
786
790
|
alwaysAvailable?: true;
|
|
787
791
|
}
|
|
788
792
|
export interface BreakRequest {
|
|
@@ -809,8 +813,8 @@ export type ImposedBreak = {
|
|
|
809
813
|
export interface BreakState {
|
|
810
814
|
approval: BreakApproval;
|
|
811
815
|
/** Whether the agent may ask at all. Distinct from the fate of a request already made. */
|
|
812
|
-
|
|
813
|
-
/** Shown when `
|
|
816
|
+
mayAsk: boolean;
|
|
817
|
+
/** Shown when `mayAsk` is false, such as "Busy hours". */
|
|
814
818
|
refusedReason?: string;
|
|
815
819
|
decisionReason?: string;
|
|
816
820
|
retryAfterMs?: number;
|
|
@@ -883,7 +887,7 @@ export interface TeamRoster {
|
|
|
883
887
|
export type PolicyKey = Exclude<keyof TaskCapabilities<"voice">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"chat">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"email">, keyof SharedTaskCapabilities> | "dial" | `skill:${string}`;
|
|
884
888
|
/** On for everyone, off for everyone, or the agent's own choice. Only `hold`, `mute` and skills may be `agent`. */
|
|
885
889
|
export type TeamPolicySetting = "on" | "off" | "agent";
|
|
886
|
-
/** One policy as the lead sees it: the setting, who set it, and `lockedBy` when a
|
|
890
|
+
/** 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. */
|
|
887
891
|
export interface TeamPolicy extends Resolved {
|
|
888
892
|
setting: TeamPolicySetting;
|
|
889
893
|
}
|
|
@@ -937,7 +941,7 @@ export interface TeamBreakCommandRequest {
|
|
|
937
941
|
* `ended` once primary handling's audio ended, and the field omitted while none should be. The
|
|
938
942
|
* provider's word -- a desk attaches and renders audio from it, never from its own senses.
|
|
939
943
|
*/
|
|
940
|
-
export type TaskMediaState = "
|
|
944
|
+
export type TaskMediaState = "started" | "ended";
|
|
941
945
|
export interface VoiceMediaSession {
|
|
942
946
|
remoteAudio: MediaStream;
|
|
943
947
|
setMuted(muted: boolean): void;
|
|
@@ -959,27 +963,27 @@ export type OpenMediaResult = {
|
|
|
959
963
|
/**
|
|
960
964
|
* What the team may leave to the person: a capability by its own name -- `hold`, `mute` -- or a
|
|
961
965
|
* skill by its provider id. The same key as in `Task.capabilities`, because it is the same
|
|
962
|
-
* capability seen at another
|
|
966
|
+
* capability seen at another level. Callback and new call are never the person's; they are the
|
|
963
967
|
* team's, on or off, within what the queue allows.
|
|
964
968
|
*/
|
|
965
969
|
export type PreferenceId = "hold" | "mute" | `skill:${string}`;
|
|
966
970
|
/**
|
|
967
|
-
* Who stated a value as it stands: a
|
|
968
|
-
* protocol's own word for "no
|
|
971
|
+
* Who stated a value as it stands: a level -- `person` among them -- or `provisioning`, the
|
|
972
|
+
* protocol's own word for "no level has said anything and the provider's default applies".
|
|
969
973
|
* Nothing is hidden for want of a row.
|
|
970
974
|
*/
|
|
971
|
-
export type SetBy =
|
|
975
|
+
export type SetBy = Level | "provider";
|
|
972
976
|
/** What every resolved value carries: who set it, and who locked it if anyone did. */
|
|
973
977
|
export interface Resolved {
|
|
974
978
|
setBy: SetBy;
|
|
975
|
-
lockedBy?:
|
|
979
|
+
lockedBy?: Level;
|
|
976
980
|
/** Given with `lockedBy`, where whoever locked it said why. */
|
|
977
981
|
reason?: string;
|
|
978
982
|
}
|
|
979
983
|
/**
|
|
980
984
|
* One choice the team may leave to the person, with where it stands and who set it. The provider
|
|
981
985
|
* keeps it: it is the person's across sessions, written through `setPreference`. Listed whether
|
|
982
|
-
* or not anyone has stated it, and even when a
|
|
986
|
+
* or not anyone has stated it, and even when a level above has since locked it.
|
|
983
987
|
*/
|
|
984
988
|
export interface AgentPreference extends Resolved {
|
|
985
989
|
id: PreferenceId;
|
|
@@ -1001,11 +1005,13 @@ export type PreferenceResult = {
|
|
|
1001
1005
|
failure: ProtocolFailure;
|
|
1002
1006
|
};
|
|
1003
1007
|
export interface Snapshot<C extends Channel = Channel> {
|
|
1004
|
-
|
|
1005
|
-
|
|
1008
|
+
transport: TransportStatus;
|
|
1009
|
+
loginId: string;
|
|
1006
1010
|
break: BreakState;
|
|
1007
1011
|
/** Every task currently owned by this agent for this provider. */
|
|
1008
1012
|
tasks: Task<C>[];
|
|
1013
|
+
/** The provider's own count of those tasks, stated rather than inferred: it must equal `tasks.length`, so a blank or unanswered state can never pass as a confirmed empty. */
|
|
1014
|
+
taskCount: number;
|
|
1009
1015
|
contacts?: Contact[];
|
|
1010
1016
|
scheduledActivities?: ScheduledActivity[];
|
|
1011
1017
|
team?: TeamRoster;
|
|
@@ -1037,13 +1043,13 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1037
1043
|
reason: "reconnected" | "provider-requested";
|
|
1038
1044
|
snapshot: Snapshot<C>;
|
|
1039
1045
|
} | {
|
|
1040
|
-
type: "
|
|
1046
|
+
type: "transport-status";
|
|
1041
1047
|
status: "connecting" | "active";
|
|
1042
1048
|
message?: string;
|
|
1043
1049
|
} | {
|
|
1044
|
-
type: "
|
|
1050
|
+
type: "transport-status";
|
|
1045
1051
|
status: "error";
|
|
1046
|
-
recovery:
|
|
1052
|
+
recovery: TransportRecovery;
|
|
1047
1053
|
message?: string;
|
|
1048
1054
|
} | {
|
|
1049
1055
|
type: "break-state";
|
|
@@ -1058,7 +1064,7 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1058
1064
|
type: "task-updated";
|
|
1059
1065
|
task: Task<C>;
|
|
1060
1066
|
} | {
|
|
1061
|
-
type: "task-media-
|
|
1067
|
+
type: "task-media-started";
|
|
1062
1068
|
taskId: TaskId;
|
|
1063
1069
|
} | {
|
|
1064
1070
|
type: "task-media-ended";
|
|
@@ -1094,7 +1100,7 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1094
1100
|
export interface ProviderEventEnvelope<C extends Channel = Channel> {
|
|
1095
1101
|
id: string;
|
|
1096
1102
|
/** The login this belongs to. */
|
|
1097
|
-
|
|
1103
|
+
loginId: string;
|
|
1098
1104
|
occurredAt: IsoTimestamp;
|
|
1099
1105
|
event: ProviderEvent<C>;
|
|
1100
1106
|
}
|
|
@@ -1219,7 +1225,7 @@ export interface BrowserSessionKeyInput {
|
|
|
1219
1225
|
* guard -- comparing the identity -- never fires on a demotion, because the thing that changed is
|
|
1220
1226
|
* not the thing being compared. Key order does not matter, and `team: {}` is not `team` absent.
|
|
1221
1227
|
*/
|
|
1222
|
-
export declare function sameCapabilities(a:
|
|
1228
|
+
export declare function sameCapabilities(a: UserCapabilities, b: UserCapabilities): boolean;
|
|
1223
1229
|
/**
|
|
1224
1230
|
* The storage-profile key a reusing browser shares, or `undefined` where it shares nothing.
|
|
1225
1231
|
*
|
package/dist/index.js
CHANGED
|
@@ -51,18 +51,18 @@ export function isAllowedBrowserUrl(url) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
/**
|
|
54
|
-
* The
|
|
55
|
-
* `
|
|
54
|
+
* The levels a typical organisation has: the ladder in force when a manifest declares no
|
|
55
|
+
* `orgLevels`. A manifest that declares any states its whole ladder outright.
|
|
56
56
|
*/
|
|
57
|
-
export const
|
|
57
|
+
export const DEFAULT_LEVELS = [
|
|
58
58
|
{ id: "org", label: "Your organisation" },
|
|
59
59
|
{ id: "site", label: "Your site" },
|
|
60
60
|
{ id: "team", label: "Your team" },
|
|
61
61
|
{ id: "person", label: "You" },
|
|
62
62
|
];
|
|
63
63
|
/** The ladder in force for a manifest: exactly what it declares, or the defaults when it declares none. */
|
|
64
|
-
export function
|
|
65
|
-
return [...(declared ??
|
|
64
|
+
export function effectiveLevels(declared) {
|
|
65
|
+
return [...(declared ?? DEFAULT_LEVELS)];
|
|
66
66
|
}
|
|
67
67
|
// ---------------------------------------------------------------------------
|
|
68
68
|
// Task commands.
|
package/dist/testing.d.ts
CHANGED
|
@@ -120,7 +120,7 @@ export declare class BreakStream {
|
|
|
120
120
|
export declare function assertBreakFollowsItsRequests(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
|
|
121
121
|
/**
|
|
122
122
|
* The media follows the task and never decides it. Given a provider's stream -- optionally seeded
|
|
123
|
-
* with the snapshot it began from -- every task is introduced once, `task-media-
|
|
123
|
+
* with the snapshot it began from -- every task is introduced once, `task-media-started` and
|
|
124
124
|
* `task-media-ended` alternate on work that has begun, media ends only where it arrived, and what
|
|
125
125
|
* follows the media ending is `completing` or `task-ended`.
|
|
126
126
|
*/
|
|
@@ -162,7 +162,7 @@ export declare function assertBreakBeginsAfterTask(steps: readonly BreakOnTaskSt
|
|
|
162
162
|
* nothing down when the provider set a clock. `toleranceMs` absorbs scheduler jitter in a real
|
|
163
163
|
* implementation; pass 0 to demand an exact match.
|
|
164
164
|
*/
|
|
165
|
-
export declare function assertWrapTimeout(task: Pick<TaskCompletion, "completionMode" | "
|
|
165
|
+
export declare function assertWrapTimeout(task: Pick<TaskCompletion, "completionMode" | "wrapAllowance">, mediaEndedAt: string, observedDeadline: string | undefined, toleranceMs?: number): void;
|
|
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. */
|
package/dist/testing.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { browserSessionKey,
|
|
1
|
+
import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
|
|
2
2
|
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostReport, validateManifest, validateResult, validateSnapshot, } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
4
4
|
/**
|
|
@@ -31,8 +31,8 @@ const STATE_SUBJECTS = [
|
|
|
31
31
|
// Pinned to the event union the way validation pins its closed sets: a type added to
|
|
32
32
|
// `ProviderEvent` without a row here, or a row it lacks, is a compile error.
|
|
33
33
|
const EVENT_TYPES = {
|
|
34
|
-
snapshot: true, "
|
|
35
|
-
"task-media-
|
|
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, "provider-summary": true,
|
|
36
36
|
"team-updated": true, "contacts-updated": true, "calendar-updated": true,
|
|
37
37
|
};
|
|
38
38
|
const CONTRACT_SUBJECTS = [
|
|
@@ -172,8 +172,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
172
172
|
let disconnectWasClean = false;
|
|
173
173
|
try {
|
|
174
174
|
authenticationState = await authentication.state();
|
|
175
|
-
const
|
|
176
|
-
violations.push(...validateAuthenticationState(authenticationState, "authentication", {
|
|
175
|
+
const levels = effectiveLevels(adapter.manifest.orgLevels).map(level => level.id);
|
|
176
|
+
violations.push(...validateAuthenticationState(authenticationState, "authentication", { levels }));
|
|
177
177
|
if (authenticationState.status !== "authenticated") {
|
|
178
178
|
throw new Error(`Adapter contract exercise requires authenticated test state, received ${authenticationState.status}`);
|
|
179
179
|
}
|
|
@@ -193,7 +193,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
193
193
|
const reader = () => ({
|
|
194
194
|
self: current().identity.id,
|
|
195
195
|
capabilities: current().capabilities,
|
|
196
|
-
|
|
196
|
+
loginId: context.loginId,
|
|
197
197
|
autoAcceptTasks: context.autoAcceptTasks ?? true,
|
|
198
198
|
});
|
|
199
199
|
// The optional methods are optional only until something declares a need for them. Each
|
|
@@ -228,7 +228,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
228
228
|
requireMethod(on, "setPreference", "the login declares capabilities.preferences");
|
|
229
229
|
};
|
|
230
230
|
unsubscribeAuthentication = authentication.subscribe(state => {
|
|
231
|
-
const own = validateAuthenticationState(state, "authentication", {
|
|
231
|
+
const own = validateAuthenticationState(state, "authentication", { levels });
|
|
232
232
|
violations.push(...own);
|
|
233
233
|
if (own.length > 0)
|
|
234
234
|
return;
|
|
@@ -590,7 +590,7 @@ const WORK_BEGUN = new Set(["in-progress", "paused", "completing"]);
|
|
|
590
590
|
export class TaskStream {
|
|
591
591
|
tasks = new Map();
|
|
592
592
|
static stated(task) {
|
|
593
|
-
const media = isRecord(task) && (task.media === "
|
|
593
|
+
const media = isRecord(task) && (task.media === "started" || task.media === "ended") ? task.media : "none";
|
|
594
594
|
return { phase: String(isRecord(task) ? task.phase : undefined), media };
|
|
595
595
|
}
|
|
596
596
|
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
@@ -637,23 +637,31 @@ export class TaskStream {
|
|
|
637
637
|
refuse("stream.taskMediaEnded.follow", `${at}.task.phase`, `after its media ended, ${id} completes or ends; ${phase} is a phase the audio does not decide`);
|
|
638
638
|
}
|
|
639
639
|
}
|
|
640
|
-
// A task replaces the task
|
|
641
|
-
|
|
640
|
+
// A task replaces the task, and an update re-states media without moving it: the
|
|
641
|
+
// transitions belong to task-media-started and task-media-ended. Releasing ended is the
|
|
642
|
+
// one move an update may make, since wrapped audio has nothing left to end.
|
|
643
|
+
{
|
|
644
|
+
const next = TaskStream.stated(event.task);
|
|
645
|
+
if (known.media !== next.media && !(known.media === "ended" && next.media === "none")) {
|
|
646
|
+
refuse("stream.taskUpdated.media", `${at}.task.media`, `a task-updated re-states media, it does not move it: ${id} held ${known.media} and the update says ${next.media}; audio arrives on task-media-started and ends on task-media-ended`);
|
|
647
|
+
}
|
|
648
|
+
this.tasks.set(id, next);
|
|
649
|
+
}
|
|
642
650
|
break;
|
|
643
|
-
case "task-media-
|
|
651
|
+
case "task-media-started":
|
|
644
652
|
if (id === undefined)
|
|
645
653
|
break;
|
|
646
654
|
if (known === undefined) {
|
|
647
|
-
refuse("stream.
|
|
655
|
+
refuse("stream.taskMediaStarted.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
648
656
|
break;
|
|
649
657
|
}
|
|
650
658
|
if (!WORK_BEGUN.has(known.phase)) {
|
|
651
|
-
refuse("stream.
|
|
659
|
+
refuse("stream.taskMediaStarted.beforeWork", `${at}.taskId`, `media cannot arrive on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
652
660
|
}
|
|
653
|
-
if (known.media === "
|
|
654
|
-
refuse("stream.
|
|
661
|
+
if (known.media === "started") {
|
|
662
|
+
refuse("stream.taskMediaStarted.duplicate", `${at}.taskId`, `media already started on ${id}; started and ended alternate`);
|
|
655
663
|
}
|
|
656
|
-
known.media = "
|
|
664
|
+
known.media = "started";
|
|
657
665
|
break;
|
|
658
666
|
case "task-media-ended":
|
|
659
667
|
if (id === undefined)
|
|
@@ -665,8 +673,8 @@ export class TaskStream {
|
|
|
665
673
|
if (!WORK_BEGUN.has(known.phase)) {
|
|
666
674
|
refuse("stream.taskMediaEnded.beforeWork", `${at}.taskId`, `media cannot end on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
667
675
|
}
|
|
668
|
-
if (known.media !== "
|
|
669
|
-
refuse("stream.taskMediaEnded.silent", `${at}.taskId`, `media cannot end on ${id} where none arrived: audio attaches on task-media-
|
|
676
|
+
if (known.media !== "started") {
|
|
677
|
+
refuse("stream.taskMediaEnded.silent", `${at}.taskId`, `media cannot end on ${id} where none arrived: audio attaches on task-media-started, or on a task carried with media started`);
|
|
670
678
|
}
|
|
671
679
|
known.media = "ended";
|
|
672
680
|
break;
|
|
@@ -748,7 +756,7 @@ export function assertBreakFollowsItsRequests(envelopes, snapshot) {
|
|
|
748
756
|
}
|
|
749
757
|
/**
|
|
750
758
|
* The media follows the task and never decides it. Given a provider's stream -- optionally seeded
|
|
751
|
-
* with the snapshot it began from -- every task is introduced once, `task-media-
|
|
759
|
+
* with the snapshot it began from -- every task is introduced once, `task-media-started` and
|
|
752
760
|
* `task-media-ended` alternate on work that has begun, media ends only where it arrived, and what
|
|
753
761
|
* follows the media ending is `completing` or `task-ended`.
|
|
754
762
|
*/
|
|
@@ -761,7 +769,7 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
|
|
|
761
769
|
assertNoViolations(found, "The media follows the task");
|
|
762
770
|
}
|
|
763
771
|
/** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
|
|
764
|
-
export function stillHost(report = { online: true
|
|
772
|
+
export function stillHost(report = { online: true }) {
|
|
765
773
|
return { report: () => report, subscribe: () => () => undefined };
|
|
766
774
|
}
|
|
767
775
|
const usableLogin = (status) => status === "authenticated" || status === "refreshing";
|
|
@@ -825,20 +833,20 @@ export function assertBreakBeginsAfterTask(steps) {
|
|
|
825
833
|
* implementation; pass 0 to demand an exact match.
|
|
826
834
|
*/
|
|
827
835
|
export function assertWrapTimeout(task, mediaEndedAt, observedDeadline, toleranceMs = 1_000) {
|
|
828
|
-
if (task.
|
|
836
|
+
if (task.wrapAllowance === undefined) {
|
|
829
837
|
if (observedDeadline !== undefined) {
|
|
830
838
|
throw new Error(`Wrap deadline mismatch: the task states no allowance, so there is no deadline, received ${observedDeadline}`);
|
|
831
839
|
}
|
|
832
840
|
return;
|
|
833
841
|
}
|
|
834
842
|
if (observedDeadline === undefined) {
|
|
835
|
-
throw new Error(`Wrap deadline mismatch: the task allows ${task.
|
|
843
|
+
throw new Error(`Wrap deadline mismatch: the task allows ${task.wrapAllowance}s, but no deadline was observed`);
|
|
836
844
|
}
|
|
837
845
|
const ended = Date.parse(mediaEndedAt);
|
|
838
846
|
const deadline = Date.parse(observedDeadline);
|
|
839
847
|
if (Number.isNaN(ended) || Number.isNaN(deadline))
|
|
840
848
|
throw new Error("Wrap scenario requires valid ISO-8601 times");
|
|
841
|
-
const expected = ended + task.
|
|
849
|
+
const expected = ended + task.wrapAllowance * 1_000;
|
|
842
850
|
if (Math.abs(deadline - expected) > toleranceMs) {
|
|
843
851
|
throw new Error(`Wrap deadline mismatch: expected ${new Date(expected).toISOString()} within ${toleranceMs}ms, received ${observedDeadline}`);
|
|
844
852
|
}
|
package/dist/validation.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ProtocolViolation, type
|
|
1
|
+
import { type ProtocolViolation, type UserCapabilities, type UserId } from "./index.js";
|
|
2
2
|
export type { ProtocolViolation } from "./index.js";
|
|
3
3
|
export declare class ProtocolConformanceError extends Error {
|
|
4
4
|
readonly violations: readonly ProtocolViolation[];
|
|
@@ -13,8 +13,8 @@ export declare function validateManifest(manifest: unknown, path?: string): Prot
|
|
|
13
13
|
export interface TaskValidationContext {
|
|
14
14
|
/** The provider's channel, from its manifest. A task must agree with it. */
|
|
15
15
|
channel: string;
|
|
16
|
-
/** The
|
|
17
|
-
|
|
16
|
+
/** The level ids in force, from the manifest. The defaults when absent. */
|
|
17
|
+
levels?: readonly string[];
|
|
18
18
|
}
|
|
19
19
|
export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
|
|
20
20
|
/**
|
|
@@ -31,11 +31,11 @@ export interface ReaderContext {
|
|
|
31
31
|
* The login's `AuthenticationState.capabilities`. The login is the permission: a lead's
|
|
32
32
|
* snapshot carries a roster, nobody else's does, and `requests` need `team.consultControl`.
|
|
33
33
|
*/
|
|
34
|
-
capabilities?:
|
|
35
|
-
/** The
|
|
36
|
-
|
|
37
|
-
/** The login's `
|
|
38
|
-
|
|
34
|
+
capabilities?: UserCapabilities;
|
|
35
|
+
/** The level ids in force. Filled from the manifest by `validateSnapshot` and `validateEventEnvelope`; the defaults otherwise. */
|
|
36
|
+
levels?: readonly string[];
|
|
37
|
+
/** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
|
|
38
|
+
loginId?: string;
|
|
39
39
|
/** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether `task-offered` carries an `acceptanceMode`. */
|
|
40
40
|
autoAcceptTasks?: boolean;
|
|
41
41
|
}
|
|
@@ -59,7 +59,7 @@ export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" |
|
|
|
59
59
|
export declare function validateResult(result: unknown, method: ResultMethod, path?: string): ProtocolViolation[];
|
|
60
60
|
/** What a login is validated against beyond its own shape. */
|
|
61
61
|
export interface LoginValidationContext {
|
|
62
|
-
/** The
|
|
63
|
-
|
|
62
|
+
/** The level ids in force, from the manifest. The defaults when absent. */
|
|
63
|
+
levels?: readonly string[];
|
|
64
64
|
}
|
|
65
65
|
export declare function validateAuthenticationState(state: unknown, path?: string, context?: LoginValidationContext): ProtocolViolation[];
|