@xema/omni-protocol 0.1.23 → 0.1.25
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 +25 -1
- package/dist/testing.d.ts +5 -3
- package/dist/testing.js +46 -11
- package/dist/validation.js +29 -0
- package/guide.md +79 -12
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -326,6 +326,12 @@ export interface ConnectContext {
|
|
|
326
326
|
log?: (entry: unknown) => void;
|
|
327
327
|
}
|
|
328
328
|
export type ConnectionStatus = "connecting" | "active" | "error";
|
|
329
|
+
/**
|
|
330
|
+
* What revives a connection that reported `error`: `reconnect` -- the login is good, dispose this
|
|
331
|
+
* connection and call `connect()` again -- or `reauthenticate` -- run the authentication flow
|
|
332
|
+
* first. The adapter knows which; the host acts on its word.
|
|
333
|
+
*/
|
|
334
|
+
export type ConnectionRecovery = "reconnect" | "reauthenticate";
|
|
329
335
|
/** Every field is optional: a provider sends what it knows and omits what it does not. */
|
|
330
336
|
export interface Contact {
|
|
331
337
|
name?: string;
|
|
@@ -590,10 +596,12 @@ export type Task<C extends Channel = Channel> = {
|
|
|
590
596
|
consultation?: TaskConsultation;
|
|
591
597
|
lead?: TaskLead;
|
|
592
598
|
assisting?: TaskAssisting;
|
|
599
|
+
media?: TaskMediaState;
|
|
593
600
|
} : {
|
|
594
601
|
consultation?: never;
|
|
595
602
|
lead?: never;
|
|
596
603
|
assisting?: never;
|
|
604
|
+
media?: never;
|
|
597
605
|
});
|
|
598
606
|
/** What the provider wants of Omni's acceptance policy for one offer. */
|
|
599
607
|
export type AcceptanceMode = "no-preference" | "require-agent-acceptance" | "require-automatic-acceptance";
|
|
@@ -924,6 +932,12 @@ export type TeamCommandResult = {
|
|
|
924
932
|
export interface TeamBreakCommandRequest {
|
|
925
933
|
command: TeamBreakCommand;
|
|
926
934
|
}
|
|
935
|
+
/**
|
|
936
|
+
* The task's real-time audio as the provider holds it: `ready` while audio should be attached,
|
|
937
|
+
* `ended` once primary handling's audio ended, and the field omitted while none should be. The
|
|
938
|
+
* provider's word -- a desk attaches and renders audio from it, never from its own senses.
|
|
939
|
+
*/
|
|
940
|
+
export type TaskMediaState = "ready" | "ended";
|
|
927
941
|
export interface VoiceMediaSession {
|
|
928
942
|
remoteAudio: MediaStream;
|
|
929
943
|
setMuted(muted: boolean): void;
|
|
@@ -992,6 +1006,8 @@ export interface Snapshot<C extends Channel = Channel> {
|
|
|
992
1006
|
break: BreakState;
|
|
993
1007
|
/** Every task currently owned by this agent for this provider. */
|
|
994
1008
|
tasks: Task<C>[];
|
|
1009
|
+
/** 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. */
|
|
1010
|
+
taskCount: number;
|
|
995
1011
|
contacts?: Contact[];
|
|
996
1012
|
scheduledActivities?: ScheduledActivity[];
|
|
997
1013
|
team?: TeamRoster;
|
|
@@ -1024,7 +1040,12 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1024
1040
|
snapshot: Snapshot<C>;
|
|
1025
1041
|
} | {
|
|
1026
1042
|
type: "provider-status";
|
|
1027
|
-
status:
|
|
1043
|
+
status: "connecting" | "active";
|
|
1044
|
+
message?: string;
|
|
1045
|
+
} | {
|
|
1046
|
+
type: "provider-status";
|
|
1047
|
+
status: "error";
|
|
1048
|
+
recovery: ConnectionRecovery;
|
|
1028
1049
|
message?: string;
|
|
1029
1050
|
} | {
|
|
1030
1051
|
type: "break-state";
|
|
@@ -1038,6 +1059,9 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1038
1059
|
} | {
|
|
1039
1060
|
type: "task-updated";
|
|
1040
1061
|
task: Task<C>;
|
|
1062
|
+
} | {
|
|
1063
|
+
type: "task-media-ready";
|
|
1064
|
+
taskId: TaskId;
|
|
1041
1065
|
} | {
|
|
1042
1066
|
type: "task-media-ended";
|
|
1043
1067
|
taskId: TaskId;
|
package/dist/testing.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation }
|
|
|
7
7
|
* rules -- each optional part of a task, each optional part of the break state and roster, each
|
|
8
8
|
* declared contribution, and each event type.
|
|
9
9
|
*/
|
|
10
|
-
declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.consultation", "task.lead", "task.assisting", "task.dispositions", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities", "team.policies"];
|
|
10
|
+
declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.consultation", "task.lead", "task.assisting", "task.media", "task.dispositions", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities", "team.policies"];
|
|
11
11
|
export type ContractSubject = (typeof STATE_SUBJECTS)[number] | `event.${ProviderEvent["type"]}`;
|
|
12
12
|
export interface AdapterContractResult {
|
|
13
13
|
events: ProviderEventEnvelope[];
|
|
@@ -98,6 +98,7 @@ export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApp
|
|
|
98
98
|
/** What a stream has said about the tasks it carries, and the rules across events. */
|
|
99
99
|
export declare class TaskStream {
|
|
100
100
|
private readonly tasks;
|
|
101
|
+
private static stated;
|
|
101
102
|
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
102
103
|
seed(snapshot: unknown): void;
|
|
103
104
|
/** Applies one envelope and returns what it may not say given what came before. */
|
|
@@ -119,8 +120,9 @@ export declare class BreakStream {
|
|
|
119
120
|
export declare function assertBreakFollowsItsRequests(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
|
|
120
121
|
/**
|
|
121
122
|
* The media follows the task and never decides it. Given a provider's stream -- optionally seeded
|
|
122
|
-
* with the snapshot it began from -- every task is introduced once, `task-media-
|
|
123
|
-
* task
|
|
123
|
+
* with the snapshot it began from -- every task is introduced once, `task-media-ready` and
|
|
124
|
+
* `task-media-ended` alternate on work that has begun, media ends only where it arrived, and what
|
|
125
|
+
* follows the media ending is `completing` or `task-ended`.
|
|
124
126
|
*/
|
|
125
127
|
export declare function assertMediaFollowsTheTask(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
|
|
126
128
|
/** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
|
package/dist/testing.js
CHANGED
|
@@ -15,6 +15,7 @@ const STATE_SUBJECTS = [
|
|
|
15
15
|
"task.consultation",
|
|
16
16
|
"task.lead",
|
|
17
17
|
"task.assisting",
|
|
18
|
+
"task.media",
|
|
18
19
|
"task.dispositions",
|
|
19
20
|
"task.destinations",
|
|
20
21
|
"task.custom",
|
|
@@ -31,7 +32,7 @@ const STATE_SUBJECTS = [
|
|
|
31
32
|
// `ProviderEvent` without a row here, or a row it lacks, is a compile error.
|
|
32
33
|
const EVENT_TYPES = {
|
|
33
34
|
snapshot: true, "provider-status": true, "break-state": true, "task-offered": true, "task-updated": true,
|
|
34
|
-
"task-media-ended": true, "task-ended": true, announcement: true, "provider-summary": true,
|
|
35
|
+
"task-media-ready": true, "task-media-ended": true, "task-ended": true, announcement: true, "provider-summary": true,
|
|
35
36
|
"team-updated": true, "contacts-updated": true, "calendar-updated": true,
|
|
36
37
|
};
|
|
37
38
|
const CONTRACT_SUBJECTS = [
|
|
@@ -58,6 +59,8 @@ function observeTask(value, seen) {
|
|
|
58
59
|
seen.add("task.lead");
|
|
59
60
|
if (value.assisting !== undefined)
|
|
60
61
|
seen.add("task.assisting");
|
|
62
|
+
if (value.media !== undefined)
|
|
63
|
+
seen.add("task.media");
|
|
61
64
|
const capabilities = isRecord(value.capabilities) ? value.capabilities : {};
|
|
62
65
|
if (isRecord(capabilities.dispositions))
|
|
63
66
|
seen.add("task.dispositions");
|
|
@@ -578,13 +581,18 @@ export function assertDeniedAndRetriedBreak(approvals) {
|
|
|
578
581
|
// ---------------------------------------------------------------------------
|
|
579
582
|
// The task stream. Each event is validated on its own; what one event may say about a task
|
|
580
583
|
// depends on what was said before, and only something that watched the whole stream can hold a
|
|
581
|
-
// provider to it. A task is never its audio: media
|
|
582
|
-
//
|
|
584
|
+
// provider to it. A task is never its audio: media arrives on the provider's word, ends only
|
|
585
|
+
// where it arrived and on work that has begun, and what follows the media ending is the work
|
|
586
|
+
// completing or ending, never a phase the audio decided.
|
|
583
587
|
// ---------------------------------------------------------------------------
|
|
584
588
|
const WORK_BEGUN = new Set(["in-progress", "paused", "completing"]);
|
|
585
589
|
/** What a stream has said about the tasks it carries, and the rules across events. */
|
|
586
590
|
export class TaskStream {
|
|
587
591
|
tasks = new Map();
|
|
592
|
+
static stated(task) {
|
|
593
|
+
const media = isRecord(task) && (task.media === "ready" || task.media === "ended") ? task.media : "none";
|
|
594
|
+
return { phase: String(isRecord(task) ? task.phase : undefined), media };
|
|
595
|
+
}
|
|
588
596
|
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
589
597
|
seed(snapshot) {
|
|
590
598
|
this.tasks.clear();
|
|
@@ -592,7 +600,7 @@ export class TaskStream {
|
|
|
592
600
|
return;
|
|
593
601
|
for (const task of snapshot.tasks) {
|
|
594
602
|
if (isRecord(task) && typeof task.id === "string")
|
|
595
|
-
this.tasks.set(task.id,
|
|
603
|
+
this.tasks.set(task.id, TaskStream.stated(task));
|
|
596
604
|
}
|
|
597
605
|
}
|
|
598
606
|
/** Applies one envelope and returns what it may not say given what came before. */
|
|
@@ -614,7 +622,7 @@ export class TaskStream {
|
|
|
614
622
|
break;
|
|
615
623
|
if (known !== undefined)
|
|
616
624
|
refuse("stream.taskOffered.duplicate", `${at}.task.id`, `${id} is already on the stream; an offer introduces a task once`);
|
|
617
|
-
this.tasks.set(id,
|
|
625
|
+
this.tasks.set(id, TaskStream.stated(event.task));
|
|
618
626
|
break;
|
|
619
627
|
case "task-updated":
|
|
620
628
|
if (id === undefined)
|
|
@@ -623,14 +631,37 @@ export class TaskStream {
|
|
|
623
631
|
refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
|
|
624
632
|
break;
|
|
625
633
|
}
|
|
626
|
-
if (known.
|
|
634
|
+
if (known.media === "ended") {
|
|
627
635
|
const phase = isRecord(event.task) ? String(event.task.phase) : "";
|
|
628
636
|
if (phase !== "completing") {
|
|
629
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`);
|
|
630
638
|
}
|
|
631
|
-
known.mediaEnded = phase === "completing" ? false : known.mediaEnded;
|
|
632
639
|
}
|
|
633
|
-
|
|
640
|
+
// A task replaces the task, and an update re-states media without moving it: the
|
|
641
|
+
// transitions belong to task-media-ready 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-ready and ends on task-media-ended`);
|
|
647
|
+
}
|
|
648
|
+
this.tasks.set(id, next);
|
|
649
|
+
}
|
|
650
|
+
break;
|
|
651
|
+
case "task-media-ready":
|
|
652
|
+
if (id === undefined)
|
|
653
|
+
break;
|
|
654
|
+
if (known === undefined) {
|
|
655
|
+
refuse("stream.taskMediaReady.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
if (!WORK_BEGUN.has(known.phase)) {
|
|
659
|
+
refuse("stream.taskMediaReady.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`);
|
|
660
|
+
}
|
|
661
|
+
if (known.media === "ready") {
|
|
662
|
+
refuse("stream.taskMediaReady.duplicate", `${at}.taskId`, `media is already ready on ${id}; ready and ended alternate`);
|
|
663
|
+
}
|
|
664
|
+
known.media = "ready";
|
|
634
665
|
break;
|
|
635
666
|
case "task-media-ended":
|
|
636
667
|
if (id === undefined)
|
|
@@ -642,7 +673,10 @@ export class TaskStream {
|
|
|
642
673
|
if (!WORK_BEGUN.has(known.phase)) {
|
|
643
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`);
|
|
644
675
|
}
|
|
645
|
-
known.
|
|
676
|
+
if (known.media !== "ready") {
|
|
677
|
+
refuse("stream.taskMediaEnded.silent", `${at}.taskId`, `media cannot end on ${id} where none arrived: audio attaches on task-media-ready, or on a task carried with media ready`);
|
|
678
|
+
}
|
|
679
|
+
known.media = "ended";
|
|
646
680
|
break;
|
|
647
681
|
case "task-ended":
|
|
648
682
|
if (id === undefined)
|
|
@@ -722,8 +756,9 @@ export function assertBreakFollowsItsRequests(envelopes, snapshot) {
|
|
|
722
756
|
}
|
|
723
757
|
/**
|
|
724
758
|
* The media follows the task and never decides it. Given a provider's stream -- optionally seeded
|
|
725
|
-
* with the snapshot it began from -- every task is introduced once, `task-media-
|
|
726
|
-
* task
|
|
759
|
+
* with the snapshot it began from -- every task is introduced once, `task-media-ready` and
|
|
760
|
+
* `task-media-ended` alternate on work that has begun, media ends only where it arrived, and what
|
|
761
|
+
* follows the media ending is `completing` or `task-ended`.
|
|
727
762
|
*/
|
|
728
763
|
export function assertMediaFollowsTheTask(envelopes, snapshot) {
|
|
729
764
|
const stream = new TaskStream();
|
package/dist/validation.js
CHANGED
|
@@ -36,6 +36,7 @@ export function assertNoViolations(violations, summary) {
|
|
|
36
36
|
*/
|
|
37
37
|
const membersOf = (members) => Object.keys(members);
|
|
38
38
|
const CHANNELS = membersOf({ voice: true, chat: true, email: true });
|
|
39
|
+
const CONNECTION_RECOVERIES = membersOf({ reconnect: true, reauthenticate: true });
|
|
39
40
|
const TASK_PHASES = membersOf({
|
|
40
41
|
pending: true, confirmed: true, preparing: true, "in-progress": true, paused: true, completing: true,
|
|
41
42
|
});
|
|
@@ -648,6 +649,15 @@ function validateHandlingHistory(value, path, into) {
|
|
|
648
649
|
});
|
|
649
650
|
}
|
|
650
651
|
/** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
|
|
652
|
+
const TASK_MEDIA_STATES = membersOf({ ready: true, ended: true });
|
|
653
|
+
/** Real-time media is a voice affair, and its state is one of two words. */
|
|
654
|
+
function validateTaskMedia(value, channel, path, into) {
|
|
655
|
+
if (value === undefined)
|
|
656
|
+
return;
|
|
657
|
+
if (!into.require(channel === "voice", "task.media.channel", path, `a ${channel} task carries no real-time media state`))
|
|
658
|
+
return;
|
|
659
|
+
into.oneOf(value, TASK_MEDIA_STATES, "task.media", path);
|
|
660
|
+
}
|
|
651
661
|
function validateConsultation(value, channel, path, into) {
|
|
652
662
|
if (value === undefined)
|
|
653
663
|
return;
|
|
@@ -738,6 +748,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
738
748
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into);
|
|
739
749
|
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into);
|
|
740
750
|
validateConsultation(task.consultation, context.channel, `${path}.consultation`, into);
|
|
751
|
+
validateTaskMedia(task.media, context.channel, `${path}.media`, into);
|
|
741
752
|
validateLead(task.lead, context.channel, `${path}.lead`, into);
|
|
742
753
|
validateAssisting(task.assisting, context.channel, `${path}.assisting`, into);
|
|
743
754
|
const capabilities = task.capabilities;
|
|
@@ -982,6 +993,12 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
982
993
|
into.require(snapshot.sessionId === context.sessionId, "snapshot.sessionId.mismatch", `${path}.sessionId`, `a snapshot for session ${String(snapshot.sessionId)} on a login whose session is ${context.sessionId}`);
|
|
983
994
|
}
|
|
984
995
|
validateBreakState(snapshot.break, `${path}.break`, into);
|
|
996
|
+
// The count is the provider's confirmation of how much work it answered with. Stated, never
|
|
997
|
+
// inferred: an unanswered or blank state lacks it, and cannot pass as a confirmed empty.
|
|
998
|
+
if (into.require(typeof snapshot.taskCount === "number" && Number.isInteger(snapshot.taskCount) && snapshot.taskCount >= 0, "snapshot.taskCount", `${path}.taskCount`, "a snapshot states its task count: a whole number, zero or more")
|
|
999
|
+
&& Array.isArray(snapshot.tasks)) {
|
|
1000
|
+
into.require(snapshot.taskCount === snapshot.tasks.length, "snapshot.taskCount.mismatch", `${path}.taskCount`, `taskCount says ${snapshot.taskCount} and tasks carries ${snapshot.tasks.length}: a count that does not reconcile is an answer nobody gave`);
|
|
1001
|
+
}
|
|
985
1002
|
if (!Array.isArray(snapshot.tasks)) {
|
|
986
1003
|
into.add("snapshot.tasks.shape", `${path}.tasks`, "a snapshot must carry a tasks array");
|
|
987
1004
|
}
|
|
@@ -1159,6 +1176,15 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1159
1176
|
break;
|
|
1160
1177
|
case "provider-status":
|
|
1161
1178
|
into.oneOf(event.status, CONNECTION_STATUSES, "event.providerStatus.status", `${at}.status`);
|
|
1179
|
+
// An error says how to revive it; any other status has nothing to revive.
|
|
1180
|
+
if (event.status === "error") {
|
|
1181
|
+
if (into.require(event.recovery !== undefined, "event.providerStatus.recovery.required", `${at}.recovery`, "an error names its recovery: reconnect, or reauthenticate")) {
|
|
1182
|
+
into.oneOf(event.recovery, CONNECTION_RECOVERIES, "event.providerStatus.recovery", `${at}.recovery`);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
else {
|
|
1186
|
+
into.require(event.recovery === undefined, "event.providerStatus.recovery.unexpected", `${at}.recovery`, "recovery goes with an error; nothing needs reviving here");
|
|
1187
|
+
}
|
|
1162
1188
|
if (event.message !== undefined) {
|
|
1163
1189
|
into.filled(event.message, "event.providerStatus.message", `${at}.message`, "a message must not be empty when present");
|
|
1164
1190
|
}
|
|
@@ -1190,6 +1216,9 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1190
1216
|
case "task-updated":
|
|
1191
1217
|
validateTaskInto(event.task, { channel, tiers }, `${at}.task`, into);
|
|
1192
1218
|
break;
|
|
1219
|
+
case "task-media-ready":
|
|
1220
|
+
into.require(isTaskId(event.taskId), "event.taskMediaReady.taskId", `${at}.taskId`, "a task id is required");
|
|
1221
|
+
break;
|
|
1193
1222
|
case "task-media-ended":
|
|
1194
1223
|
into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
|
|
1195
1224
|
break;
|
package/guide.md
CHANGED
|
@@ -363,6 +363,7 @@ type Snapshot = {
|
|
|
363
363
|
sessionId: string;
|
|
364
364
|
break: BreakState;
|
|
365
365
|
tasks: Task[];
|
|
366
|
+
taskCount: number;
|
|
366
367
|
contacts?: Contact[];
|
|
367
368
|
scheduledActivities?: ScheduledActivity[];
|
|
368
369
|
team?: TeamRoster;
|
|
@@ -582,6 +583,8 @@ type Locked = {
|
|
|
582
583
|
|
|
583
584
|
type Lockable<T> = T | Locked;
|
|
584
585
|
|
|
586
|
+
type TaskMediaState = "ready" | "ended";
|
|
587
|
+
|
|
585
588
|
type Task<C extends Channel = Channel> = {
|
|
586
589
|
id: TaskId;
|
|
587
590
|
title: string;
|
|
@@ -596,8 +599,8 @@ type Task<C extends Channel = Channel> = {
|
|
|
596
599
|
handlingHistory?: TaskHandlingStep[];
|
|
597
600
|
} & TaskCompletion & (
|
|
598
601
|
C extends "voice"
|
|
599
|
-
? { consultation?: TaskConsultation; lead?: TaskLead; assisting?: TaskAssisting }
|
|
600
|
-
: { consultation?: never; lead?: never; assisting?: never }
|
|
602
|
+
? { consultation?: TaskConsultation; lead?: TaskLead; assisting?: TaskAssisting; media?: TaskMediaState }
|
|
603
|
+
: { consultation?: never; lead?: never; assisting?: never; media?: never }
|
|
601
604
|
);
|
|
602
605
|
|
|
603
606
|
type AcceptanceMode =
|
|
@@ -919,9 +922,12 @@ type ProviderSummary = {
|
|
|
919
922
|
metrics?: SummaryMetric[];
|
|
920
923
|
};
|
|
921
924
|
|
|
925
|
+
type ConnectionRecovery = "reconnect" | "reauthenticate";
|
|
926
|
+
|
|
922
927
|
type ProviderEvent =
|
|
923
928
|
| { type: "snapshot"; reason: "reconnected" | "provider-requested"; snapshot: Snapshot }
|
|
924
|
-
| { type: "provider-status"; status:
|
|
929
|
+
| { type: "provider-status"; status: "connecting" | "active"; message?: string }
|
|
930
|
+
| { type: "provider-status"; status: "error"; recovery: ConnectionRecovery; message?: string }
|
|
925
931
|
| { type: "break-state"; break: BreakState }
|
|
926
932
|
| {
|
|
927
933
|
type: "task-offered";
|
|
@@ -931,6 +937,7 @@ type ProviderEvent =
|
|
|
931
937
|
preparationEndsAt?: IsoTimestamp;
|
|
932
938
|
}
|
|
933
939
|
| { type: "task-updated"; task: Task }
|
|
940
|
+
| { type: "task-media-ready"; taskId: TaskId }
|
|
934
941
|
| { type: "task-media-ended"; taskId: TaskId }
|
|
935
942
|
| { type: "task-ended"; taskId: TaskId; outcome: TaskOutcome }
|
|
936
943
|
| { type: "announcement"; text: string; html?: string; announcedAt: IsoTimestamp; expiresAt?: IsoTimestamp }
|
|
@@ -1217,6 +1224,17 @@ which field says so varies. See **Which commands need a capability**.
|
|
|
1217
1224
|
|
|
1218
1225
|
Snapshots establish and replace provider state when an agent signs in, reconnects, or resynchronises.
|
|
1219
1226
|
|
|
1227
|
+
**A snapshot is the provider's answer, and an adapter that got no answer publishes nothing.** A
|
|
1228
|
+
state read that answers unknown — a session not yet associated, a backend mid-failover — is not an
|
|
1229
|
+
empty state: the adapter keeps what it holds, stays `connecting`, and publishes a snapshot only
|
|
1230
|
+
once the provider has actually answered for this login, exactly as `connect()` may not resolve
|
|
1231
|
+
before it can provide a meaningful one. And emptiness is stated, never inferred: every snapshot
|
|
1232
|
+
carries `taskCount`, the provider's own count reconciled against `tasks.length`, so a snapshot
|
|
1233
|
+
with no work says `taskCount: 0` in so many words and a blank or half-built state — which lacks
|
|
1234
|
+
the count — can never pass as a confirmed empty. Absence of knowledge is never evidence of
|
|
1235
|
+
absence, and every place "empty" is allowed to carry both meanings will eventually clear
|
|
1236
|
+
somebody's live call.
|
|
1237
|
+
|
|
1220
1238
|
Events report completed transactions after that baseline. Nothing is missed while the connection
|
|
1221
1239
|
holds; when it drops, the reconnect snapshot re-establishes the baseline before any further event
|
|
1222
1240
|
is applied.
|
|
@@ -1793,6 +1811,9 @@ Creates one live provider connection for the signed-in agent.
|
|
|
1793
1811
|
- May reject for authentication, configuration, or startup failure.
|
|
1794
1812
|
- Must not create a second agent session merely because the underlying transport reconnects.
|
|
1795
1813
|
- The returned connection owns reconnect until Omni calls `disconnect()` or aborts `context.signal`.
|
|
1814
|
+
- May be called again on the same login after that: once per `Connection`, not once per login.
|
|
1815
|
+
Omni disposes a connection whose `error` named `recovery: "reconnect"` with `disconnect()` and
|
|
1816
|
+
calls `connect()` afresh — see **`provider-status`**.
|
|
1796
1817
|
|
|
1797
1818
|
### `ConnectContext`
|
|
1798
1819
|
|
|
@@ -1828,6 +1849,7 @@ a capability it agrees with the login: a lead's snapshot carries `team`, nobody
|
|
|
1828
1849
|
| `sessionId` | Identity of this login session. It must match the connection context. |
|
|
1829
1850
|
| `break` | Complete break state, including approval, accepting state, reasons, retry details, and any imposed break. |
|
|
1830
1851
|
| `tasks` | Complete set of tasks currently offered to or owned by this agent. |
|
|
1852
|
+
| `taskCount` | The provider's own count of those tasks, stated rather than inferred, and it must equal `tasks.length`. A snapshot with no work says `taskCount: 0` in so many words — a blank or unanswered state lacks the count and cannot pass as a confirmed empty. |
|
|
1831
1853
|
| `contacts` | Required complete contact contribution when the manifest declares `contacts`; `[]` clears it. Omitted only when it does not. |
|
|
1832
1854
|
| `scheduledActivities` | Required complete calendar contribution when the manifest declares `calendar`; `[]` clears it. Omitted only when it does not. |
|
|
1833
1855
|
| `team` | Required `TeamRoster` when the login declares `capabilities.team`, `[]` when nobody is in it. Forbidden otherwise — the login is the permission. |
|
|
@@ -2048,6 +2070,7 @@ time. Runtime conformance checks also require the task channel to match its prov
|
|
|
2048
2070
|
| `browsers` | Named browser definitions for the task workspace: at least one when the task declares the `browsers` capability, empty when it does not. |
|
|
2049
2071
|
| `contact` | Optional `Contact` for the person or entity on this task. Often a name and one address; a withheld caller ID may leave nothing to send at all. |
|
|
2050
2072
|
| `phase` | Current canonical task phase: `pending`, `confirmed`, `preparing`, `in-progress`, `paused`, or `completing`. |
|
|
2073
|
+
| `media` | Voice only. The task's real-time audio as the provider holds it: `ready` while audio should be attached, `ended` once it ended, omitted while none should be. The provider's word — see **`task-media-ready`**. |
|
|
2051
2074
|
| `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
|
|
2052
2075
|
| `completionMode` | `agent-command` waits for the channel's `complete` command; `provider-automatic` completes without one. |
|
|
2053
2076
|
| `completionAllowance` | Fixed time allowed to complete the task after primary handling ends. For real-time media, it begins after `task-media-ended`. Required under `provider-automatic`, where the provider acts on it. Optional under `agent-command`: omitted says the provider imposes no deadline, and Omni counts nothing down. |
|
|
@@ -2107,7 +2130,8 @@ command.
|
|
|
2107
2130
|
routed to the agent and accepted as `acceptanceMode` dictates, and its presence and phase follow
|
|
2108
2131
|
the provider's reports about the work — never the audio. Wherever audio moves — an offer, a hold, a
|
|
2109
2132
|
consult, a conference leg joining or leaving, a transfer, a callback — the media follows
|
|
2110
|
-
separately, attaching through `openMedia` and ending with
|
|
2133
|
+
separately, arriving on `task-media-ready`, attaching through `openMedia` and ending with
|
|
2134
|
+
`task-media-ended`. Omni does not ring,
|
|
2111
2135
|
bridge, or hold a line. How the phone rings, whether it rings at all, and where legs join and leave
|
|
2112
2136
|
are the adapter's and the platform's, transient, and decide neither when a task exists nor what
|
|
2113
2137
|
phase it is in.
|
|
@@ -2118,9 +2142,10 @@ allowance starts on it and the callback control appears on it — and Omni follo
|
|
|
2118
2142
|
follows any other. What Omni never does is derive a task's state from its own media session: a
|
|
2119
2143
|
stream that drops, a track that ends, a transport that disconnects, a microphone that fails, an
|
|
2120
2144
|
endpoint re-registering change nothing about the task until the provider says so. Structurally:
|
|
2121
|
-
`task-media-ended`
|
|
2122
|
-
|
|
2123
|
-
|
|
2145
|
+
`task-media-ready` and `task-media-ended` alternate on a task whose work has begun, media ends
|
|
2146
|
+
only where it arrived, what follows the media ending is `completing` or `task-ended`, and every
|
|
2147
|
+
task is introduced once — `exerciseAdapter` holds the stream to that from the connect snapshot on,
|
|
2148
|
+
and `assertMediaFollowsTheTask` holds any sequence.
|
|
2124
2149
|
|
|
2125
2150
|
#### Completion timing
|
|
2126
2151
|
|
|
@@ -3233,6 +3258,11 @@ carries as `audio.input.localAudio`, and absent while that input is `unavailable
|
|
|
3233
3258
|
bridges audio without a host-side input may ignore it; one that needs it and finds it absent
|
|
3234
3259
|
answers `unavailable` with a failure Omni shows the agent.
|
|
3235
3260
|
|
|
3261
|
+
**When to ask is the provider's word, not Omni's guess.** Omni opens media on `task-media-ready`,
|
|
3262
|
+
and on a task arriving with `media: "ready"` on a snapshot; it closes on `task-media-ended` and
|
|
3263
|
+
when the task ends. Between those words, nothing Omni's own senses report — a stream that drops, a
|
|
3264
|
+
track that ends — moves the task or its audio.
|
|
3265
|
+
|
|
3236
3266
|
**A task-scoped session does not oblige one call per task.** A platform holding a nailed-up
|
|
3237
3267
|
leg for a whole shift may return the same session for every task and release the underlying
|
|
3238
3268
|
path only when the connection closes. A platform placing a call per contact returns a new one
|
|
@@ -3385,7 +3415,10 @@ silently lose a message, so while the connection is up Omni has seen everything
|
|
|
3385
3415
|
Loss has exactly one shape: the connection went away. The adapter reports `connecting` or `error`,
|
|
3386
3416
|
reconnects, and emits a `snapshot` event carrying complete state. That snapshot is the repair —
|
|
3387
3417
|
whatever was missed while the connection was down is in it, and Omni replaces its provider view
|
|
3388
|
-
rather than reasoning about what it did not receive.
|
|
3418
|
+
rather than reasoning about what it did not receive. A repair is an answer like any other: a
|
|
3419
|
+
platform that has not yet answered for this login after a reconnect — a state read served empty by
|
|
3420
|
+
a backend that does not know the session yet — yields no snapshot, and the adapter stays
|
|
3421
|
+
`connecting` holding what it holds. See **Snapshots establish state; events report transactions**.
|
|
3389
3422
|
|
|
3390
3423
|
A snapshot must account for **everything the adapter has emitted before it resolves**, not merely
|
|
3391
3424
|
everything emitted when it was requested. Omni discards events buffered during the read on that
|
|
@@ -3424,7 +3457,24 @@ safe for the agent to see.
|
|
|
3424
3457
|
| --- | --- |
|
|
3425
3458
|
| `connecting` | No usable transport right now, and the adapter expects to recover on its own. Nobody needs to act. Startup and every reconnect pass through this value. |
|
|
3426
3459
|
| `active` | The transport is up and the provider is serving this session. It is the only value under which work arrives. |
|
|
3427
|
-
| `error` | The adapter cannot serve the session and is not simply mid-reconnect. Say why in `message
|
|
3460
|
+
| `error` | The adapter cannot serve the session and is not simply mid-reconnect. Say why in `message`, and say what revives it in `recovery` — required here, forbidden on any other status. It is not terminal: an adapter that recovers on its own still reports `connecting` and then `active`, and one that cannot is revived as `recovery` says. |
|
|
3461
|
+
|
|
3462
|
+
**An error names its recovery, and the host acts on that word.** The adapter knows why its session
|
|
3463
|
+
died; the host knows how to run a login. `recovery` joins the two:
|
|
3464
|
+
|
|
3465
|
+
- **`reconnect`** — the login is good and this connection is not: a backend restart, a session the
|
|
3466
|
+
platform no longer recognises. Omni calls `disconnect()` on the dead connection and then
|
|
3467
|
+
`connect()` again on the same login — same `sessionId` — and the fresh connect snapshot
|
|
3468
|
+
re-establishes state exactly as a reconnect snapshot does. `connect()` is once per
|
|
3469
|
+
`Connection`, not once per login.
|
|
3470
|
+
- **`reauthenticate`** — the session under the login died: a token rejected, a remote logout. Omni
|
|
3471
|
+
runs the authentication flow first; the authentication session decides whether stored material
|
|
3472
|
+
refreshes it silently or the agent must act, exactly as at sign-in.
|
|
3473
|
+
|
|
3474
|
+
**Patience is the host's.** An adapter in `connecting` retries for as long as it takes and never
|
|
3475
|
+
has to decide when to stop. Omni owns giving up: after however long it chooses to wait, it may
|
|
3476
|
+
call `disconnect()` and either `connect()` afresh or surface the failure — so neither side waits
|
|
3477
|
+
for the other to blink.
|
|
3428
3478
|
|
|
3429
3479
|
**Status is about the transport, nothing else.** It does not say whether the agent is available,
|
|
3430
3480
|
whether they are on a break, or how much work they can take: capacity travels on `setCapacity`,
|
|
@@ -3474,10 +3524,27 @@ snapshots until it ends.
|
|
|
3474
3524
|
Replaces the current representation of one provider-local task. It is a full task value, not a
|
|
3475
3525
|
partial patch.
|
|
3476
3526
|
|
|
3527
|
+
### `task-media-ready`
|
|
3528
|
+
|
|
3529
|
+
The provider's word that the task's audio should now attach. Omni calls `openMedia` on it — and on
|
|
3530
|
+
a task carried with `media: "ready"`, which is how a reconnect snapshot reattaches audio an
|
|
3531
|
+
earlier event brought — and renders the call as live from that word, never from its own senses. It
|
|
3532
|
+
names a task whose work has begun, and it alternates with `task-media-ended`: media that was never
|
|
3533
|
+
made ready cannot end, so a live call whose provider says nothing about its audio is a provider in
|
|
3534
|
+
breach, not a state a desk fills in from its own devices.
|
|
3535
|
+
|
|
3536
|
+
The event is the transition and the task's `media` field is the state. A `task-updated` re-states
|
|
3537
|
+
the media its task already holds — republishing `ready` on a hold is a statement, not a second
|
|
3538
|
+
arrival — but it does not move it: an update that itself flips the field is refused
|
|
3539
|
+
(`stream.taskUpdated.media`), and the pairing at the moment audio arrives is the phase change
|
|
3540
|
+
without the field, then the event. Releasing `ended` is the one move an update may make, since
|
|
3541
|
+
wrapped audio has nothing left to end.
|
|
3542
|
+
|
|
3477
3543
|
### `task-media-ended`
|
|
3478
3544
|
|
|
3479
3545
|
Signals that a task's real-time media ended. For voice and similar channels, this starts the fixed
|
|
3480
|
-
completion timer. It does not remove the task
|
|
3546
|
+
completion timer. It does not remove the task, and it ends only audio that `task-media-ready` — or
|
|
3547
|
+
a task carried with `media: "ready"` — attached.
|
|
3481
3548
|
|
|
3482
3549
|
### `task-ended`
|
|
3483
3550
|
|
|
@@ -3566,7 +3633,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
3566
3633
|
| --- | --- |
|
|
3567
3634
|
| `validateManifest(manifest)` | Identity, protocol-version interoperability, authentication methods, and idle-capability shapes. |
|
|
3568
3635
|
| `validateTask(task, { channel })` | Identity, channel agreement, phase, completion allowance, capability shapes, custom controls, and browsers. |
|
|
3569
|
-
| `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team roster, and every task, contact, and activity, including idle-capability gating both ways: a contribution the manifest never declared is refused, and one it declares is required, `[]` included. |
|
|
3636
|
+
| `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team roster, the stated `taskCount` reconciled against the tasks carried, and every task, contact, and activity, including idle-capability gating both ways: a contribution the manifest never declared is refused, and one it declares is required, `[]` included. |
|
|
3570
3637
|
| `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
|
|
3571
3638
|
| `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
|
|
3572
3639
|
| `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
|
|
@@ -3660,7 +3727,7 @@ cannot be established from TypeScript structure alone.
|
|
|
3660
3727
|
| `stillHost(report?)` | A host that reports one thing and never changes, for a test context: `{ online: true }` by default, a report with audio for a voice adapter. |
|
|
3661
3728
|
| `TaskStream`, `BreakStream` | The cross-event models the harness applies after the connect snapshot, exported for a host that wants the same rules at its boundary: `seed(snapshot)`, then `apply(envelope)` returns the violations. |
|
|
3662
3729
|
| `assertBreakFollowsItsRequests(envelopes, snapshot?)` | A break follows its requests: a commit's states only after a grant, never backwards, and a placed break arriving in effect with `imposed`. The harness applies the same rules after the connect snapshot. |
|
|
3663
|
-
| `assertMediaFollowsTheTask(envelopes, snapshot?)` | The media follows the task and never decides it: every task is introduced once, `task-media-
|
|
3730
|
+
| `assertMediaFollowsTheTask(envelopes, snapshot?)` | The media follows the task and never decides it: every task is introduced once, `task-media-ready` and `task-media-ended` alternate on work that has begun, media ends only where it arrived, and what follows the media ending is `completing` or `task-ended`. The harness applies the same rules to every event after the connect snapshot (`stream.*`). A sequence with no media satisfies it by never testing it — pair it with the assertion that the media end is present. |
|
|
3664
3731
|
| `assertBreakParticipants(candidates, participants)` | A break attempt asks every usable provider holding capacity, `refreshing` included, and nothing of a provider whose login is `expired`. |
|
|
3665
3732
|
| `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. |
|
|
3666
3733
|
| `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
|