@xema/omni-protocol 0.1.71 → 0.1.73
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 +7 -0
- package/dist/testing.js +48 -10
- package/dist/validation.js +7 -0
- package/guide.md +19 -3
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -174,6 +174,13 @@ export interface Manifest<C extends Channel = Channel> {
|
|
|
174
174
|
* when it ended, and a running report is refused: what a provider never asked for never crosses.
|
|
175
175
|
*/
|
|
176
176
|
runningStepReports?: true;
|
|
177
|
+
/**
|
|
178
|
+
* How long after an applied disposal -- `complete`, `transfer` `complete`, `lead-assist`
|
|
179
|
+
* `take-over` -- the provider's `task-ended` is owed, in milliseconds. `applied` says the provider
|
|
180
|
+
* has disposed of the task; the ending follows within this, or the host resyncs and shows the
|
|
181
|
+
* task as unsettled. Stated per provider, since platforms settle at different speeds.
|
|
182
|
+
*/
|
|
183
|
+
disposalSettleMs: number;
|
|
177
184
|
}
|
|
178
185
|
export interface SecretStore {
|
|
179
186
|
get(key: string): Promise<string | undefined>;
|
package/dist/testing.js
CHANGED
|
@@ -1055,7 +1055,7 @@ export class TaskStream {
|
|
|
1055
1055
|
case "task-updated":
|
|
1056
1056
|
if (id === undefined)
|
|
1057
1057
|
break;
|
|
1058
|
-
ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.allocation", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
|
|
1058
|
+
ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.allocation", "stream.taskUpdated.mediaOpen", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
|
|
1059
1059
|
if (known === undefined) {
|
|
1060
1060
|
refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
|
|
1061
1061
|
break;
|
|
@@ -1094,6 +1094,15 @@ export class TaskStream {
|
|
|
1094
1094
|
refuse("stream.taskUpdated.handlingHistory", `${at}.task.handlingHistory`, `${id}'s record lost ${lost.join(", ")} on the update: an entry read by the host stays in the record until the task ends`);
|
|
1095
1095
|
}
|
|
1096
1096
|
}
|
|
1097
|
+
// A task completes after its media ends, never around it: an update that moves a task to
|
|
1098
|
+
// completing while the stream holds its audio as started is a call whose audio never ended,
|
|
1099
|
+
// whoever caused the ending -- the drive's end-call, a transfer, the provider's own hand.
|
|
1100
|
+
{
|
|
1101
|
+
const to = isRecord(event.task) ? String(event.task.phase) : "";
|
|
1102
|
+
if (to === "completing" && known.media === "started" && known.phase !== "completing") {
|
|
1103
|
+
refuse("stream.taskUpdated.mediaOpen", `${at}.task.phase`, `${id} moves to completing with its media still started: the audio ends first, on task-media-ended, and a wrap-up with the call still up is audio that never ended`);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1097
1106
|
if (known.media === "ended") {
|
|
1098
1107
|
const phase = isRecord(event.task) ? String(event.task.phase) : "";
|
|
1099
1108
|
if (phase !== "completing") {
|
|
@@ -1208,7 +1217,7 @@ async function driveOneCall(drive) {
|
|
|
1208
1217
|
const refuse = (rule, path, message) => found.push({ rule, path, message });
|
|
1209
1218
|
const isTask = (value) => isRecord(value) && typeof value.id === "string";
|
|
1210
1219
|
// Wait for an envelope that satisfies `matches`, looking first at what already arrived.
|
|
1211
|
-
const waitFor = (what, matches, from) => new Promise(resolve => {
|
|
1220
|
+
const waitFor = (what, matches, from, within = { ms: drive.timeoutMs, onExpiry: () => refuse("drive.timeout", "drive", `the provider owed ${what} within ${drive.timeoutMs}ms and it never arrived`) }) => new Promise(resolve => {
|
|
1212
1221
|
for (let index = from; index < drive.events.length; index += 1) {
|
|
1213
1222
|
const hit = matches(drive.events[index]);
|
|
1214
1223
|
if (hit !== undefined) {
|
|
@@ -1218,9 +1227,9 @@ async function driveOneCall(drive) {
|
|
|
1218
1227
|
}
|
|
1219
1228
|
const timer = setTimeout(() => {
|
|
1220
1229
|
drive.waiters.delete(waiter);
|
|
1221
|
-
|
|
1230
|
+
within.onExpiry();
|
|
1222
1231
|
resolve(undefined);
|
|
1223
|
-
},
|
|
1232
|
+
}, within.ms);
|
|
1224
1233
|
const waiter = (envelope) => {
|
|
1225
1234
|
const hit = matches(envelope);
|
|
1226
1235
|
if (hit === undefined)
|
|
@@ -1359,10 +1368,10 @@ async function driveOneCall(drive) {
|
|
|
1359
1368
|
return undefined;
|
|
1360
1369
|
}, cursor).then(hit => { if (hit)
|
|
1361
1370
|
cursor = hit.at; return hit; });
|
|
1362
|
-
const ended = () => waitFor("a task-ended for the driven task", envelope => {
|
|
1371
|
+
const ended = (within) => waitFor("a task-ended for the driven task", envelope => {
|
|
1363
1372
|
const event = envelope.event;
|
|
1364
1373
|
return event.type === "task-ended" && event.taskId === taskId ? event : undefined;
|
|
1365
|
-
}, cursor);
|
|
1374
|
+
}, cursor, within);
|
|
1366
1375
|
// Every command the drive sends is validated against the task as published, and its answer for its method.
|
|
1367
1376
|
ruleEvaluated("drive.timeout");
|
|
1368
1377
|
const send = async (command, dialId) => {
|
|
@@ -1519,11 +1528,18 @@ async function driveOneCall(drive) {
|
|
|
1519
1528
|
// 5. End the call, where the agent may.
|
|
1520
1529
|
if (drive.channel === "voice" && latestTask().phase === "in-progress" && offers("endCall")) {
|
|
1521
1530
|
if (await send({ type: "end-call" }) !== undefined) {
|
|
1531
|
+
// A task that completes with its audio still up is refused by the stream as it passes
|
|
1532
|
+
// (stream.taskUpdated.mediaOpen); the drive does not also wait out the clock for an ending
|
|
1533
|
+
// that is not coming, so the run reads by the rule and not by its timeout.
|
|
1522
1534
|
const mediaEnded = await waitFor("task-media-ended after end-call", envelope => {
|
|
1523
1535
|
const event = envelope.event;
|
|
1524
|
-
|
|
1536
|
+
if (event.type === "task-media-ended" && event.taskId === taskId)
|
|
1537
|
+
return "ended";
|
|
1538
|
+
if (event.type === "task-updated" && isTask(event.task) && event.task.id === taskId && event.task.phase === "completing")
|
|
1539
|
+
return "completing";
|
|
1540
|
+
return undefined;
|
|
1525
1541
|
}, cursor);
|
|
1526
|
-
if (mediaEnded
|
|
1542
|
+
if (mediaEnded?.found === "ended")
|
|
1527
1543
|
cursor = mediaEnded.at;
|
|
1528
1544
|
if (await updated(t => t.phase === "completing", "the task completing after its media ended") !== undefined && offers("hold")) {
|
|
1529
1545
|
await holdRefusedOutsideHandling();
|
|
@@ -1548,8 +1564,30 @@ async function driveOneCall(drive) {
|
|
|
1548
1564
|
if (isRecord(dispositions) && dispositions.required === true && Array.isArray(dispositions.codes) && isRecord(dispositions.codes[0])) {
|
|
1549
1565
|
command.disposition = dispositions.codes[0].id;
|
|
1550
1566
|
}
|
|
1551
|
-
if (await send(command) !== undefined
|
|
1552
|
-
|
|
1567
|
+
if (await send(command) !== undefined) {
|
|
1568
|
+
// applied says the provider has disposed of the task, and its ending follows within the bound
|
|
1569
|
+
// the manifest stated. Past it the host resyncs: a snapshot still carrying the task is a task
|
|
1570
|
+
// held open by a provider that said it was done, and the desk shows it as unsettled.
|
|
1571
|
+
ruleEvaluated("drive.disposal.unsettled");
|
|
1572
|
+
const settle = Number(drive.manifest.disposalSettleMs);
|
|
1573
|
+
let unsettled = false;
|
|
1574
|
+
const end = await ended({ ms: settle, onExpiry: () => { unsettled = true; } });
|
|
1575
|
+
if (end !== undefined)
|
|
1576
|
+
await storeReleased();
|
|
1577
|
+
else if (unsettled) {
|
|
1578
|
+
let resync;
|
|
1579
|
+
try {
|
|
1580
|
+
resync = await drive.connection.snapshot();
|
|
1581
|
+
}
|
|
1582
|
+
catch (error) {
|
|
1583
|
+
refuse("drive.command.rejected", "drive.disposal", `snapshot() after an unsettled disposal rejected: ${String(error)}`);
|
|
1584
|
+
}
|
|
1585
|
+
const still = isRecord(resync) && Array.isArray(resync.tasks) && resync.tasks.some(t => isRecord(t) && t.id === taskId && t.allocationId === allocationOf());
|
|
1586
|
+
refuse("drive.disposal.unsettled", "drive.disposal", still
|
|
1587
|
+
? `complete was applied and ${settle}ms later the task-ended has not come and a snapshot still carries ${taskId}: applied says the provider disposed of the task, and it has not`
|
|
1588
|
+
: `complete was applied and ${settle}ms later the task-ended has not come; a snapshot no longer carries ${taskId}, so the ending was owed and never sent`);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1553
1591
|
}
|
|
1554
1592
|
else if (latestTask().phase === "completing") {
|
|
1555
1593
|
if (await ended() !== undefined)
|
package/dist/validation.js
CHANGED
|
@@ -422,6 +422,7 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
422
422
|
if (manifest.runningStepReports !== undefined) {
|
|
423
423
|
into.require(manifest.runningStepReports === true, "manifest.runningStepReports", `${path}.runningStepReports`, "runningStepReports is declared by presence, as true; a provider that takes begin and end only omits it");
|
|
424
424
|
}
|
|
425
|
+
into.require(typeof manifest.disposalSettleMs === "number" && Number.isInteger(manifest.disposalSettleMs) && manifest.disposalSettleMs > 0, "manifest.disposalSettleMs", `${path}.disposalSettleMs`, "disposalSettleMs is how long after an applied disposal the task-ended is owed, a positive whole number of milliseconds, stated by every provider");
|
|
425
426
|
if (manifest.orgLevels !== undefined) {
|
|
426
427
|
if (!Array.isArray(manifest.orgLevels)) {
|
|
427
428
|
into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
|
|
@@ -1021,6 +1022,12 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1021
1022
|
// The room is who is on the call now, and a task outlives its call by the whole of wrap-up: a
|
|
1022
1023
|
// task whose call has ended -- completing, or media ended -- carries nobody, or the last thing the
|
|
1023
1024
|
// wire said about the call stays true for ever. Empty and absent both say nobody.
|
|
1025
|
+
// A completing task's call is over: its media has ended, or it never started. Audio still stated
|
|
1026
|
+
// as started on a task in wrap-up is a call whose audio never ended -- the customer heard through
|
|
1027
|
+
// the agent's notes -- and a task stating it contradicts itself.
|
|
1028
|
+
if (task.phase === "completing" && task.media === "started") {
|
|
1029
|
+
into.add("task.media.completing", `${path}.media`, "a completing task's media has ended or never started: the call is over, and media still started is audio that never ended");
|
|
1030
|
+
}
|
|
1024
1031
|
if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
|
|
1025
1032
|
into.add("task.onCall.ended", `${path}.onCall`, `the call has ended (${task.phase === "completing" ? "the task is completing" : "its media ended"}) and onCall still names people on it: the room is who is on the call now, and now nobody is`);
|
|
1026
1033
|
}
|
package/guide.md
CHANGED
|
@@ -193,6 +193,7 @@ type Manifest<C extends Channel = Channel> = {
|
|
|
193
193
|
dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
|
|
194
194
|
phones?: C extends "voice" ? Phone[] : never;
|
|
195
195
|
runningStepReports?: true;
|
|
196
|
+
disposalSettleMs: number;
|
|
196
197
|
};
|
|
197
198
|
```
|
|
198
199
|
|
|
@@ -1471,6 +1472,7 @@ export default defineAdapter({
|
|
|
1471
1472
|
channel: "voice",
|
|
1472
1473
|
supportedProtocolVersions: [OMNI_PROTOCOL_VERSION],
|
|
1473
1474
|
authenticationMethods: ["browser-sso"],
|
|
1475
|
+
disposalSettleMs: 5000,
|
|
1474
1476
|
idleCapabilities: {
|
|
1475
1477
|
dial: { destinations: "any-number" },
|
|
1476
1478
|
},
|
|
@@ -1506,6 +1508,7 @@ compile time.
|
|
|
1506
1508
|
| `phones` | Voice only, and required there: the phones this platform can put an agent on, `softphone` (the call's audio lands in the host) and/or `deskPhone` (a handset the platform rings; the host shows the call and opens nothing). The host picks one per login. See **How the agent hears the call**. |
|
|
1507
1509
|
| `dialOutcomes` | Voice only. How a dial can end on this platform, as it distinguishes them: `answered` and at least one way of not reaching the destination. Required of a provider that dials at all — an idle dialpad, or tasks that transfer, conference or call back — and a `dial-outcome` carries only a declared member. See **Every dial has an outcome**. |
|
|
1508
1510
|
| `runningStepReports` | The provider takes running reports of a host-performed step — `recordStep` with `seconds` so far and no `ended`. Omitted, the host sends exactly two reports per leg, when it began and when it ended, and a running one is refused. See **The host records what it performs**. |
|
|
1511
|
+
| `disposalSettleMs` | Required. How long after an applied disposal -- `complete`, `transfer` `complete`, `lead-assist` `take-over` -- the provider's `task-ended` is owed, a positive whole number of milliseconds (`manifest.disposalSettleMs`). Stated per provider, since platforms settle at different speeds. See **`task-ended`**. |
|
|
1509
1512
|
|
|
1510
1513
|
### Authentication methods
|
|
1511
1514
|
|
|
@@ -2854,7 +2857,12 @@ which the update itself shows: the party on the call carrying a `stage`. A compl
|
|
|
2854
2857
|
republished as `in-progress` from a stale copy carries no such stage, and that is the ending the
|
|
2855
2858
|
agent never saw. Audio arrives only on a task at work: `task-media-started` on a `completing` task
|
|
2856
2859
|
is refused as it is on a pending one (`stream.taskMediaStarted.beforeWork`), since a connect-back
|
|
2857
|
-
returns the task to `in-progress` before any media.
|
|
2860
|
+
returns the task to `in-progress` before any media. And a task completes after its audio ends,
|
|
2861
|
+
never around it: an update moving a task to `completing` while the stream holds its media as
|
|
2862
|
+
started is refused (`stream.taskUpdated.mediaOpen`), whoever caused the ending, and a task stating
|
|
2863
|
+
`completing` with `media: "started"` contradicts itself on any snapshot or update
|
|
2864
|
+
(`task.media.completing`). The consequence the rule exists for is concrete: the customer's audio
|
|
2865
|
+
keeps playing through the agent's wrap-up.
|
|
2858
2866
|
|
|
2859
2867
|
What the agent is told differs by source, and only one source tells them anything. Under `queue`
|
|
2860
2868
|
and `ungoverned` the agent sees controls and nothing about where they came from: both are facts,
|
|
@@ -4487,8 +4495,16 @@ Every outcome ends the task for this agent. On `task-ended`, Omni:
|
|
|
4487
4495
|
A `left` outcome ends the task for this agent alone: the call continues without them, as it does
|
|
4488
4496
|
when a lead who joined it leaves -- see **Lead assist**.
|
|
4489
4497
|
|
|
4490
|
-
A successful `complete` or `transfer` command does not clear the task. Omni waits for `task-ended
|
|
4491
|
-
|
|
4498
|
+
A successful `complete` or `transfer` command does not clear the task. Omni waits for `task-ended`,
|
|
4499
|
+
and not for ever: `applied` to a disposal -- `complete`, `transfer` `complete`, `lead-assist`
|
|
4500
|
+
`take-over` -- says the provider has disposed of the task, and its `task-ended` follows within the
|
|
4501
|
+
manifest's `disposalSettleMs`. A provider never answers `applied` for a disposal it has not yet
|
|
4502
|
+
performed. Past the bound the host calls `snapshot()`: a snapshot still carrying the task is a task
|
|
4503
|
+
held open by a provider that said it was done, and the desk shows it as unsettled -- "Completing...
|
|
4504
|
+
the provider has not confirmed" -- naming the command; a snapshot no longer carrying it clears the
|
|
4505
|
+
task, since the ending was owed and lost. The drive holds a provider to the same bound
|
|
4506
|
+
(`drive.disposal.unsettled`). The `task-media-ended` event and the `completing` phase are likewise
|
|
4507
|
+
non-terminal. A replacement
|
|
4492
4508
|
snapshot that no longer contains the task also clears it. Repeated `task-ended` delivery with the
|
|
4493
4509
|
same envelope ID is harmless, and a `task-ended` naming an allocation that has already ended is
|
|
4494
4510
|
recognised as the late event it is, never applied to the life now open under the same id.
|