@xema/omni-protocol 0.1.35 → 0.1.37
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 +8 -4
- package/dist/testing.js +21 -4
- package/dist/validation.js +6 -1
- package/guide.md +26 -10
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -576,15 +576,18 @@ export type TaskCompletion = {
|
|
|
576
576
|
wrapAllowance: DurationSeconds;
|
|
577
577
|
};
|
|
578
578
|
export type OnCallRole = "party" | "agent" | "consulted" | "conferenced";
|
|
579
|
+
/** Where a dialled entry stands: `ringing` until its dial is answered, `joined` after. */
|
|
580
|
+
export type OnCallStage = "ringing" | "joined";
|
|
579
581
|
export declare const ON_CALL_ROLES: readonly ["party", "agent", "consulted", "conferenced"];
|
|
580
582
|
/**
|
|
581
583
|
* Somebody on the call, or being brought onto it, as the provider states it: who, since when, and
|
|
582
584
|
* whether they are held aside. `party` is the customer; `agent` a person, by user id; `consulted`
|
|
583
585
|
* and `conferenced` someone a dial is bringing in -- listed from the moment the dial is placed, so
|
|
584
|
-
* the agent can call it off while it rings -- carrying the dial when a host placed it
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
586
|
+
* the agent can call it off while it rings -- carrying the dial when a host placed it, the address
|
|
587
|
+
* either way, and the `stage` it has reached: `ringing` until the dial is answered, `joined` after,
|
|
588
|
+
* stated so a snapshot says who is present without anyone having seen the outcome. Nobody ringing is
|
|
589
|
+
* held. `label` names a destination -- a person, a queue -- not a phrase. At most one `party` and
|
|
590
|
+
* one `consulted`: the consult commands name neither because there is exactly one.
|
|
588
591
|
*/
|
|
589
592
|
export type OnCall = {
|
|
590
593
|
since: IsoTimestamp;
|
|
@@ -597,6 +600,7 @@ export type OnCall = {
|
|
|
597
600
|
} | {
|
|
598
601
|
role: "consulted" | "conferenced";
|
|
599
602
|
destination: string;
|
|
603
|
+
stage: OnCallStage;
|
|
600
604
|
dialId?: DialId;
|
|
601
605
|
label?: string;
|
|
602
606
|
});
|
package/dist/testing.js
CHANGED
|
@@ -619,7 +619,7 @@ export class TaskStream {
|
|
|
619
619
|
tasks = new Map();
|
|
620
620
|
// Every dial the stream can place an outcome against: one the host said it placed, or one a
|
|
621
621
|
// task carried on `onCall` or in its record -- which is how a dial made before a transfer is known
|
|
622
|
-
// to whoever holds the task now. `ended` once its outcome arrived, since
|
|
622
|
+
// to whoever holds the task now. `answered` or `ended` once its outcome arrived, since it comes once.
|
|
623
623
|
dials = new Map();
|
|
624
624
|
/** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
|
|
625
625
|
dialled(dialId) {
|
|
@@ -640,7 +640,16 @@ export class TaskStream {
|
|
|
640
640
|
}
|
|
641
641
|
static stated(task) {
|
|
642
642
|
const media = isRecord(task) && (task.media === "started" || task.media === "ended") ? task.media : "none";
|
|
643
|
-
|
|
643
|
+
// The stage of every dialled entry the room names by its dial, so an update can be held to the
|
|
644
|
+
// outcome that moves it.
|
|
645
|
+
const stages = new Map();
|
|
646
|
+
if (isRecord(task) && Array.isArray(task.onCall)) {
|
|
647
|
+
for (const entry of task.onCall) {
|
|
648
|
+
if (isRecord(entry) && typeof entry.dialId === "string" && typeof entry.stage === "string")
|
|
649
|
+
stages.set(entry.dialId, entry.stage);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return { phase: String(isRecord(task) ? task.phase : undefined), media, stages };
|
|
644
653
|
}
|
|
645
654
|
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
646
655
|
seed(snapshot) {
|
|
@@ -696,6 +705,14 @@ export class TaskStream {
|
|
|
696
705
|
if (known.media !== next.media && !(known.media === "ended" && next.media === "none")) {
|
|
697
706
|
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`);
|
|
698
707
|
}
|
|
708
|
+
// The same pairing for a dialled entry: the dial-outcome is the transition, the stage is
|
|
709
|
+
// the state, and the outcome comes first. An update that joins an entry on its own is
|
|
710
|
+
// moving what only an answered outcome moves.
|
|
711
|
+
for (const [dialId, stage] of next.stages) {
|
|
712
|
+
if (stage === "joined" && known.stages.get(dialId) === "ringing" && this.dials.get(dialId) !== "answered") {
|
|
713
|
+
refuse("stream.taskUpdated.stage", `${at}.task.onCall`, `a task-updated re-states a stage, it does not move it: ${dialId} was ringing and the update says joined before any answered dial-outcome for it`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
699
716
|
this.tasks.set(id, next);
|
|
700
717
|
}
|
|
701
718
|
this.noteDials(event.task);
|
|
@@ -746,10 +763,10 @@ export class TaskStream {
|
|
|
746
763
|
if (dial === undefined) {
|
|
747
764
|
refuse("stream.dialOutcome.unknown", `${at}.dialId`, `${event.dialId} is not a dial the host placed or a task carried: an outcome ends a dial somebody made`);
|
|
748
765
|
}
|
|
749
|
-
else if (dial
|
|
766
|
+
else if (dial !== "placed") {
|
|
750
767
|
refuse("stream.dialOutcome.duplicate", `${at}.dialId`, `${event.dialId} already has its outcome; a dial ends once`);
|
|
751
768
|
}
|
|
752
|
-
this.dials.set(event.dialId, "ended");
|
|
769
|
+
this.dials.set(event.dialId, event.outcome === "answered" ? "answered" : "ended");
|
|
753
770
|
break;
|
|
754
771
|
}
|
|
755
772
|
default:
|
package/dist/validation.js
CHANGED
|
@@ -71,6 +71,7 @@ const DIAL_OUTCOMES = membersOf({ answered: true, busy: true, "no-answer": true,
|
|
|
71
71
|
/** The outcomes that name a cause. `unexplained` is declarable only beside one of these. */
|
|
72
72
|
const EXPLAINED_FAILURES = ["busy", "no-answer", "unreachable", "rejected", "cancelled"];
|
|
73
73
|
const ON_CALL_ROLES = membersOf({ party: true, agent: true, consulted: true, conferenced: true });
|
|
74
|
+
const ON_CALL_STAGES = membersOf({ ringing: true, joined: true });
|
|
74
75
|
/** The task capabilities under which a command dials, and so need the manifest to say how a dial ends. */
|
|
75
76
|
const DIALLING_CAPABILITIES = ["callback", "blindTransfer", "consultTransfer", "conference"];
|
|
76
77
|
const SNAPSHOT_REASONS = membersOf({
|
|
@@ -778,13 +779,17 @@ function validateOnCall(value, channel, path, into) {
|
|
|
778
779
|
}
|
|
779
780
|
if (role === "consulted" || role === "conferenced") {
|
|
780
781
|
into.filled(entry.destination, "task.onCall.destination", `${at}.destination`, `a ${role} entry names the destination that was dialled`);
|
|
782
|
+
// Stated, so a snapshot says who is present without anyone having seen the dial's outcome.
|
|
783
|
+
if (into.oneOf(entry.stage, ON_CALL_STAGES, "task.onCall.stage", `${at}.stage`)) {
|
|
784
|
+
into.require(!(entry.stage === "ringing" && entry.held === true), "task.onCall.held.ringing", `${at}.held`, "nobody ringing is held; held belongs to somebody who has joined");
|
|
785
|
+
}
|
|
781
786
|
if (entry.dialId !== undefined)
|
|
782
787
|
into.filled(entry.dialId, "task.onCall.dialId", `${at}.dialId`, "dialId must not be empty when present");
|
|
783
788
|
if (entry.label !== undefined)
|
|
784
789
|
into.filled(entry.label, "task.onCall.label", `${at}.label`, "a label must not be empty when present");
|
|
785
790
|
}
|
|
786
791
|
else {
|
|
787
|
-
for (const field of ["destination", "dialId", "label"]) {
|
|
792
|
+
for (const field of ["destination", "dialId", "label", "stage"]) {
|
|
788
793
|
into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a ${role} was dialled from nowhere; ${field} belongs on consulted or conferenced`);
|
|
789
794
|
}
|
|
790
795
|
}
|
package/guide.md
CHANGED
|
@@ -589,10 +589,12 @@ type TaskCompletion =
|
|
|
589
589
|
|
|
590
590
|
type OnCallRole = "party" | "agent" | "consulted" | "conferenced";
|
|
591
591
|
|
|
592
|
+
type OnCallStage = "ringing" | "joined";
|
|
593
|
+
|
|
592
594
|
type OnCall = { since: IsoTimestamp; held?: true } & (
|
|
593
595
|
| { role: "party" }
|
|
594
596
|
| { role: "agent"; userId: UserId }
|
|
595
|
-
| { role: "consulted" | "conferenced"; destination: string; dialId?: DialId; label?: string }
|
|
597
|
+
| { role: "consulted" | "conferenced"; destination: string; stage: OnCallStage; dialId?: DialId; label?: string }
|
|
596
598
|
);
|
|
597
599
|
|
|
598
600
|
type TaskLead = {
|
|
@@ -2133,7 +2135,7 @@ time. Runtime conformance checks also require the task channel to match its prov
|
|
|
2133
2135
|
| `wrapAllowance` | Fixed time allowed to complete the task after primary handling ends. For real-time media, it begins after `task-media-ended`. Required under `provider-automatic`, where the provider acts on it. Optional under `agent-command`: omitted says the provider imposes no deadline, and Omni counts nothing down. |
|
|
2134
2136
|
| `attributes` | Optional ordered, typed `TaskAttribute` entries with keys unique within the task. Each contact or timestamp is a separate array item; new attribute shapes require new union members. |
|
|
2135
2137
|
| `handlingHistory` | The call record: `steps` — the ordered handling history of this open task, one entry per occurrence, oldest first — and what they add up to before this agent, `handleSeconds`, `holdSeconds`, `queueSeconds`, `transfers`, each present when the provider knows it. Live task data restated with the task, not a permanent archive. See **How a task has been handled**. |
|
|
2136
|
-
| `onCall` | Voice only. Who is on the call, or being brought onto it, as the provider states it, replaced whole with the task: `party` is the customer, `agent` a person by user id, `consulted` and `conferenced` somebody a dial is bringing in, listed from the moment the dial is placed -- with the `destination` dialled, the `dialId` where a host placed it, and `held: true` on anyone parked. A `consulted` entry is what makes `transfer` `complete` and `cancel` issuable. `label` names a destination -- a person, a queue -- not a phrase; the host supplies the verb. Present when the provider knows the room, absent when it does not. See **Every dial has an outcome**. |
|
|
2138
|
+
| `onCall` | Voice only. Who is on the call, or being brought onto it, as the provider states it, replaced whole with the task: `party` is the customer, `agent` a person by user id, `consulted` and `conferenced` somebody a dial is bringing in, listed from the moment the dial is placed -- with the `destination` dialled, the `dialId` where a host placed it, the `stage` reached (`ringing` until answered, `joined` after), and `held: true` on anyone joined and parked. A `consulted` entry is what makes `transfer` `complete` and `cancel` issuable. `label` names a destination -- a person, a queue -- not a phrase; the host supplies the verb. Present when the provider knows the room, absent when it does not. See **Every dial has an outcome**. |
|
|
2137
2139
|
| `lead` | Voice only. Present from the agent's request for a lead until the lead leaves or the request ends: `requested` while nobody has joined, `joined` with the lead's `leadId` once somebody has. See **Consulting a lead**. |
|
|
2138
2140
|
| `assisting` | Voice only, on the lead's own task for a call they joined: which member asked, with their note. Its presence is what makes `lead` `take-over` and `leave` issuable. See **Consulting a lead**. |
|
|
2139
2141
|
|
|
@@ -2787,10 +2789,13 @@ set: `answered`, `busy`, `no-answer`, `unreachable`, `rejected`, `cancelled`, `u
|
|
|
2787
2789
|
`answered` is stated rather than read off somebody appearing on the call, because a host that infers
|
|
2788
2790
|
success cannot tell "reached" from "still ringing". `cancelled` is the dialler calling the dial off
|
|
2789
2791
|
before anyone answered -- the agent hanging up while a consult still rings -- which is not a
|
|
2790
|
-
`no-answer` the destination never gave. `unexplained` is the switch dropping the call
|
|
2791
|
-
|
|
2792
|
-
the switch's own words, optional, and shown to the agent attributed to the switch rather
|
|
2793
|
-
Omni.
|
|
2792
|
+
`no-answer` the destination never gave. `unexplained` is the switch dropping the call with no cause among
|
|
2793
|
+
those named here: a statement about the switch, sent instead of the nearest cause it did not give.
|
|
2794
|
+
`reason` is the switch's own words, optional, and shown to the agent attributed to the switch rather
|
|
2795
|
+
than to Omni. The two are different things and may travel together: a switch can say "could not
|
|
2796
|
+
create dialog" in words and still name no cause a code would carry, so `unexplained` with a
|
|
2797
|
+
`reason` is a dial the switch described but did not classify, and a desk renders the words without
|
|
2798
|
+
inventing the class.
|
|
2794
2799
|
|
|
2795
2800
|
**`taskId` is the task the dial was placed on**, resolved from the dial's own identity and never from
|
|
2796
2801
|
whatever task the agent is looking at when the outcome arrives. That task may already have ended,
|
|
@@ -2832,7 +2837,8 @@ knows the room:
|
|
|
2832
2837
|
const onCall: OnCall[] = [
|
|
2833
2838
|
{ role: "party", since: "2026-08-21T09:12:00Z" },
|
|
2834
2839
|
{ role: "agent", userId: "A-12", since: "2026-08-21T09:12:04Z" },
|
|
2835
|
-
{ role: "conferenced", destination: "+14155550111", dialId: "dial-7f2", held: true, since: "2026-08-21T09:15:30Z" },
|
|
2840
|
+
{ role: "conferenced", destination: "+14155550111", dialId: "dial-7f2", stage: "joined", held: true, since: "2026-08-21T09:15:30Z" },
|
|
2841
|
+
{ role: "conferenced", destination: "+14155550199", dialId: "dial-3c9", stage: "ringing", since: "2026-08-21T09:16:02Z" },
|
|
2836
2842
|
];
|
|
2837
2843
|
```
|
|
2838
2844
|
|
|
@@ -2847,9 +2853,19 @@ never saw.
|
|
|
2847
2853
|
what makes `transfer` `cancel` issuable and what `conference` `remove` names, and a destination the
|
|
2848
2854
|
agent cannot call off while it rings is a parked customer with no way back -- which is the very case
|
|
2849
2855
|
`cancelled` exists for. Consult and conference behave alike here: the `consulted` or `conferenced`
|
|
2850
|
-
entry appears with its `dialId` when the dial is placed,
|
|
2851
|
-
|
|
2852
|
-
|
|
2856
|
+
entry appears with its `dialId` when the dial is placed, and a `conference` `remove` naming a
|
|
2857
|
+
still-ringing `destination` calls that dial off, with `cancelled` as its outcome.
|
|
2858
|
+
|
|
2859
|
+
**The entry says where it stands.** A dialled entry carries `stage`: `ringing` until its dial is
|
|
2860
|
+
answered, `joined` after. The `dial-outcome` is the transition and the stage is the state, the same
|
|
2861
|
+
pairing as `task-media-started` and `media`: an `answered` outcome and the task restated with the
|
|
2862
|
+
entry `joined` say one thing twice, and any other outcome removes the entry. The outcome comes
|
|
2863
|
+
first: a `task-updated` that itself moves an entry from `ringing` to `joined` before an `answered`
|
|
2864
|
+
outcome for its dial is refused (`stream.taskUpdated.stage`), as an update that moves `media` is. It is stated rather
|
|
2865
|
+
than left to whoever placed the dial because that knowledge lives in one process: after a reload,
|
|
2866
|
+
on a second session, or on a supervisor's screen the snapshot is the only source, and a room that
|
|
2867
|
+
cannot tell ringing from joined says it contains someone who may never arrive. Nobody ringing is
|
|
2868
|
+
held (`task.onCall.held.ringing`). A consult destination that never answers is a dial that
|
|
2853
2869
|
ended: the provider returns the task to `in-progress` with no `consulted` entry, as **Consult
|
|
2854
2870
|
transfer** states, so the customer is never left parked by a conforming provider.
|
|
2855
2871
|
|