@xema/omni-protocol 0.1.23 → 0.1.24
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 +23 -1
- package/dist/testing.d.ts +5 -3
- package/dist/testing.js +38 -11
- package/dist/validation.js +23 -0
- package/guide.md +54 -10
- 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;
|
|
@@ -1024,7 +1038,12 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1024
1038
|
snapshot: Snapshot<C>;
|
|
1025
1039
|
} | {
|
|
1026
1040
|
type: "provider-status";
|
|
1027
|
-
status:
|
|
1041
|
+
status: "connecting" | "active";
|
|
1042
|
+
message?: string;
|
|
1043
|
+
} | {
|
|
1044
|
+
type: "provider-status";
|
|
1045
|
+
status: "error";
|
|
1046
|
+
recovery: ConnectionRecovery;
|
|
1028
1047
|
message?: string;
|
|
1029
1048
|
} | {
|
|
1030
1049
|
type: "break-state";
|
|
@@ -1038,6 +1057,9 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1038
1057
|
} | {
|
|
1039
1058
|
type: "task-updated";
|
|
1040
1059
|
task: Task<C>;
|
|
1060
|
+
} | {
|
|
1061
|
+
type: "task-media-ready";
|
|
1062
|
+
taskId: TaskId;
|
|
1041
1063
|
} | {
|
|
1042
1064
|
type: "task-media-ended";
|
|
1043
1065
|
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,29 @@ 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: the update's own media field is the state now stated.
|
|
641
|
+
this.tasks.set(id, TaskStream.stated(event.task));
|
|
642
|
+
break;
|
|
643
|
+
case "task-media-ready":
|
|
644
|
+
if (id === undefined)
|
|
645
|
+
break;
|
|
646
|
+
if (known === undefined) {
|
|
647
|
+
refuse("stream.taskMediaReady.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
if (!WORK_BEGUN.has(known.phase)) {
|
|
651
|
+
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`);
|
|
652
|
+
}
|
|
653
|
+
if (known.media === "ready") {
|
|
654
|
+
refuse("stream.taskMediaReady.duplicate", `${at}.taskId`, `media is already ready on ${id}; ready and ended alternate`);
|
|
655
|
+
}
|
|
656
|
+
known.media = "ready";
|
|
634
657
|
break;
|
|
635
658
|
case "task-media-ended":
|
|
636
659
|
if (id === undefined)
|
|
@@ -642,7 +665,10 @@ export class TaskStream {
|
|
|
642
665
|
if (!WORK_BEGUN.has(known.phase)) {
|
|
643
666
|
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
667
|
}
|
|
645
|
-
known.
|
|
668
|
+
if (known.media !== "ready") {
|
|
669
|
+
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`);
|
|
670
|
+
}
|
|
671
|
+
known.media = "ended";
|
|
646
672
|
break;
|
|
647
673
|
case "task-ended":
|
|
648
674
|
if (id === undefined)
|
|
@@ -722,8 +748,9 @@ export function assertBreakFollowsItsRequests(envelopes, snapshot) {
|
|
|
722
748
|
}
|
|
723
749
|
/**
|
|
724
750
|
* 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
|
|
751
|
+
* with the snapshot it began from -- every task is introduced once, `task-media-ready` and
|
|
752
|
+
* `task-media-ended` alternate on work that has begun, media ends only where it arrived, and what
|
|
753
|
+
* follows the media ending is `completing` or `task-ended`.
|
|
727
754
|
*/
|
|
728
755
|
export function assertMediaFollowsTheTask(envelopes, snapshot) {
|
|
729
756
|
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;
|
|
@@ -1159,6 +1170,15 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1159
1170
|
break;
|
|
1160
1171
|
case "provider-status":
|
|
1161
1172
|
into.oneOf(event.status, CONNECTION_STATUSES, "event.providerStatus.status", `${at}.status`);
|
|
1173
|
+
// An error says how to revive it; any other status has nothing to revive.
|
|
1174
|
+
if (event.status === "error") {
|
|
1175
|
+
if (into.require(event.recovery !== undefined, "event.providerStatus.recovery.required", `${at}.recovery`, "an error names its recovery: reconnect, or reauthenticate")) {
|
|
1176
|
+
into.oneOf(event.recovery, CONNECTION_RECOVERIES, "event.providerStatus.recovery", `${at}.recovery`);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
else {
|
|
1180
|
+
into.require(event.recovery === undefined, "event.providerStatus.recovery.unexpected", `${at}.recovery`, "recovery goes with an error; nothing needs reviving here");
|
|
1181
|
+
}
|
|
1162
1182
|
if (event.message !== undefined) {
|
|
1163
1183
|
into.filled(event.message, "event.providerStatus.message", `${at}.message`, "a message must not be empty when present");
|
|
1164
1184
|
}
|
|
@@ -1190,6 +1210,9 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1190
1210
|
case "task-updated":
|
|
1191
1211
|
validateTaskInto(event.task, { channel, tiers }, `${at}.task`, into);
|
|
1192
1212
|
break;
|
|
1213
|
+
case "task-media-ready":
|
|
1214
|
+
into.require(isTaskId(event.taskId), "event.taskMediaReady.taskId", `${at}.taskId`, "a task id is required");
|
|
1215
|
+
break;
|
|
1193
1216
|
case "task-media-ended":
|
|
1194
1217
|
into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
|
|
1195
1218
|
break;
|
package/guide.md
CHANGED
|
@@ -582,6 +582,8 @@ type Locked = {
|
|
|
582
582
|
|
|
583
583
|
type Lockable<T> = T | Locked;
|
|
584
584
|
|
|
585
|
+
type TaskMediaState = "ready" | "ended";
|
|
586
|
+
|
|
585
587
|
type Task<C extends Channel = Channel> = {
|
|
586
588
|
id: TaskId;
|
|
587
589
|
title: string;
|
|
@@ -596,8 +598,8 @@ type Task<C extends Channel = Channel> = {
|
|
|
596
598
|
handlingHistory?: TaskHandlingStep[];
|
|
597
599
|
} & TaskCompletion & (
|
|
598
600
|
C extends "voice"
|
|
599
|
-
? { consultation?: TaskConsultation; lead?: TaskLead; assisting?: TaskAssisting }
|
|
600
|
-
: { consultation?: never; lead?: never; assisting?: never }
|
|
601
|
+
? { consultation?: TaskConsultation; lead?: TaskLead; assisting?: TaskAssisting; media?: TaskMediaState }
|
|
602
|
+
: { consultation?: never; lead?: never; assisting?: never; media?: never }
|
|
601
603
|
);
|
|
602
604
|
|
|
603
605
|
type AcceptanceMode =
|
|
@@ -919,9 +921,12 @@ type ProviderSummary = {
|
|
|
919
921
|
metrics?: SummaryMetric[];
|
|
920
922
|
};
|
|
921
923
|
|
|
924
|
+
type ConnectionRecovery = "reconnect" | "reauthenticate";
|
|
925
|
+
|
|
922
926
|
type ProviderEvent =
|
|
923
927
|
| { type: "snapshot"; reason: "reconnected" | "provider-requested"; snapshot: Snapshot }
|
|
924
|
-
| { type: "provider-status"; status:
|
|
928
|
+
| { type: "provider-status"; status: "connecting" | "active"; message?: string }
|
|
929
|
+
| { type: "provider-status"; status: "error"; recovery: ConnectionRecovery; message?: string }
|
|
925
930
|
| { type: "break-state"; break: BreakState }
|
|
926
931
|
| {
|
|
927
932
|
type: "task-offered";
|
|
@@ -931,6 +936,7 @@ type ProviderEvent =
|
|
|
931
936
|
preparationEndsAt?: IsoTimestamp;
|
|
932
937
|
}
|
|
933
938
|
| { type: "task-updated"; task: Task }
|
|
939
|
+
| { type: "task-media-ready"; taskId: TaskId }
|
|
934
940
|
| { type: "task-media-ended"; taskId: TaskId }
|
|
935
941
|
| { type: "task-ended"; taskId: TaskId; outcome: TaskOutcome }
|
|
936
942
|
| { type: "announcement"; text: string; html?: string; announcedAt: IsoTimestamp; expiresAt?: IsoTimestamp }
|
|
@@ -1793,6 +1799,9 @@ Creates one live provider connection for the signed-in agent.
|
|
|
1793
1799
|
- May reject for authentication, configuration, or startup failure.
|
|
1794
1800
|
- Must not create a second agent session merely because the underlying transport reconnects.
|
|
1795
1801
|
- The returned connection owns reconnect until Omni calls `disconnect()` or aborts `context.signal`.
|
|
1802
|
+
- May be called again on the same login after that: once per `Connection`, not once per login.
|
|
1803
|
+
Omni disposes a connection whose `error` named `recovery: "reconnect"` with `disconnect()` and
|
|
1804
|
+
calls `connect()` afresh — see **`provider-status`**.
|
|
1796
1805
|
|
|
1797
1806
|
### `ConnectContext`
|
|
1798
1807
|
|
|
@@ -2048,6 +2057,7 @@ time. Runtime conformance checks also require the task channel to match its prov
|
|
|
2048
2057
|
| `browsers` | Named browser definitions for the task workspace: at least one when the task declares the `browsers` capability, empty when it does not. |
|
|
2049
2058
|
| `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
2059
|
| `phase` | Current canonical task phase: `pending`, `confirmed`, `preparing`, `in-progress`, `paused`, or `completing`. |
|
|
2060
|
+
| `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
2061
|
| `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
|
|
2052
2062
|
| `completionMode` | `agent-command` waits for the channel's `complete` command; `provider-automatic` completes without one. |
|
|
2053
2063
|
| `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 +2117,8 @@ command.
|
|
|
2107
2117
|
routed to the agent and accepted as `acceptanceMode` dictates, and its presence and phase follow
|
|
2108
2118
|
the provider's reports about the work — never the audio. Wherever audio moves — an offer, a hold, a
|
|
2109
2119
|
consult, a conference leg joining or leaving, a transfer, a callback — the media follows
|
|
2110
|
-
separately, attaching through `openMedia` and ending with
|
|
2120
|
+
separately, arriving on `task-media-ready`, attaching through `openMedia` and ending with
|
|
2121
|
+
`task-media-ended`. Omni does not ring,
|
|
2111
2122
|
bridge, or hold a line. How the phone rings, whether it rings at all, and where legs join and leave
|
|
2112
2123
|
are the adapter's and the platform's, transient, and decide neither when a task exists nor what
|
|
2113
2124
|
phase it is in.
|
|
@@ -2118,9 +2129,10 @@ allowance starts on it and the callback control appears on it — and Omni follo
|
|
|
2118
2129
|
follows any other. What Omni never does is derive a task's state from its own media session: a
|
|
2119
2130
|
stream that drops, a track that ends, a transport that disconnects, a microphone that fails, an
|
|
2120
2131
|
endpoint re-registering change nothing about the task until the provider says so. Structurally:
|
|
2121
|
-
`task-media-ended`
|
|
2122
|
-
|
|
2123
|
-
|
|
2132
|
+
`task-media-ready` and `task-media-ended` alternate on a task whose work has begun, media ends
|
|
2133
|
+
only where it arrived, what follows the media ending is `completing` or `task-ended`, and every
|
|
2134
|
+
task is introduced once — `exerciseAdapter` holds the stream to that from the connect snapshot on,
|
|
2135
|
+
and `assertMediaFollowsTheTask` holds any sequence.
|
|
2124
2136
|
|
|
2125
2137
|
#### Completion timing
|
|
2126
2138
|
|
|
@@ -3233,6 +3245,11 @@ carries as `audio.input.localAudio`, and absent while that input is `unavailable
|
|
|
3233
3245
|
bridges audio without a host-side input may ignore it; one that needs it and finds it absent
|
|
3234
3246
|
answers `unavailable` with a failure Omni shows the agent.
|
|
3235
3247
|
|
|
3248
|
+
**When to ask is the provider's word, not Omni's guess.** Omni opens media on `task-media-ready`,
|
|
3249
|
+
and on a task arriving with `media: "ready"` on a snapshot; it closes on `task-media-ended` and
|
|
3250
|
+
when the task ends. Between those words, nothing Omni's own senses report — a stream that drops, a
|
|
3251
|
+
track that ends — moves the task or its audio.
|
|
3252
|
+
|
|
3236
3253
|
**A task-scoped session does not oblige one call per task.** A platform holding a nailed-up
|
|
3237
3254
|
leg for a whole shift may return the same session for every task and release the underlying
|
|
3238
3255
|
path only when the connection closes. A platform placing a call per contact returns a new one
|
|
@@ -3424,7 +3441,24 @@ safe for the agent to see.
|
|
|
3424
3441
|
| --- | --- |
|
|
3425
3442
|
| `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
3443
|
| `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
|
|
3444
|
+
| `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. |
|
|
3445
|
+
|
|
3446
|
+
**An error names its recovery, and the host acts on that word.** The adapter knows why its session
|
|
3447
|
+
died; the host knows how to run a login. `recovery` joins the two:
|
|
3448
|
+
|
|
3449
|
+
- **`reconnect`** — the login is good and this connection is not: a backend restart, a session the
|
|
3450
|
+
platform no longer recognises. Omni calls `disconnect()` on the dead connection and then
|
|
3451
|
+
`connect()` again on the same login — same `sessionId` — and the fresh connect snapshot
|
|
3452
|
+
re-establishes state exactly as a reconnect snapshot does. `connect()` is once per
|
|
3453
|
+
`Connection`, not once per login.
|
|
3454
|
+
- **`reauthenticate`** — the session under the login died: a token rejected, a remote logout. Omni
|
|
3455
|
+
runs the authentication flow first; the authentication session decides whether stored material
|
|
3456
|
+
refreshes it silently or the agent must act, exactly as at sign-in.
|
|
3457
|
+
|
|
3458
|
+
**Patience is the host's.** An adapter in `connecting` retries for as long as it takes and never
|
|
3459
|
+
has to decide when to stop. Omni owns giving up: after however long it chooses to wait, it may
|
|
3460
|
+
call `disconnect()` and either `connect()` afresh or surface the failure — so neither side waits
|
|
3461
|
+
for the other to blink.
|
|
3428
3462
|
|
|
3429
3463
|
**Status is about the transport, nothing else.** It does not say whether the agent is available,
|
|
3430
3464
|
whether they are on a break, or how much work they can take: capacity travels on `setCapacity`,
|
|
@@ -3474,10 +3508,20 @@ snapshots until it ends.
|
|
|
3474
3508
|
Replaces the current representation of one provider-local task. It is a full task value, not a
|
|
3475
3509
|
partial patch.
|
|
3476
3510
|
|
|
3511
|
+
### `task-media-ready`
|
|
3512
|
+
|
|
3513
|
+
The provider's word that the task's audio should now attach. Omni calls `openMedia` on it — and on
|
|
3514
|
+
a task carried with `media: "ready"`, which is how a reconnect snapshot reattaches audio an
|
|
3515
|
+
earlier event brought — and renders the call as live from that word, never from its own senses. It
|
|
3516
|
+
names a task whose work has begun, and it alternates with `task-media-ended`: media that was never
|
|
3517
|
+
made ready cannot end, so a live call whose provider says nothing about its audio is a provider in
|
|
3518
|
+
breach, not a state a desk fills in from its own devices.
|
|
3519
|
+
|
|
3477
3520
|
### `task-media-ended`
|
|
3478
3521
|
|
|
3479
3522
|
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
|
|
3523
|
+
completion timer. It does not remove the task, and it ends only audio that `task-media-ready` — or
|
|
3524
|
+
a task carried with `media: "ready"` — attached.
|
|
3481
3525
|
|
|
3482
3526
|
### `task-ended`
|
|
3483
3527
|
|
|
@@ -3660,7 +3704,7 @@ cannot be established from TypeScript structure alone.
|
|
|
3660
3704
|
| `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
3705
|
| `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
3706
|
| `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-
|
|
3707
|
+
| `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
3708
|
| `assertBreakParticipants(candidates, participants)` | A break attempt asks every usable provider holding capacity, `refreshing` included, and nothing of a provider whose login is `expired`. |
|
|
3665
3709
|
| `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
3710
|
| `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
|