@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 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
- refuse("drive.timeout", "drive", `the provider owed ${what} within ${drive.timeoutMs}ms and it never arrived`);
1230
+ within.onExpiry();
1222
1231
  resolve(undefined);
1223
- }, drive.timeoutMs);
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
- return event.type === "task-media-ended" && event.taskId === taskId ? event : undefined;
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 !== undefined)
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 && await ended() !== undefined)
1552
- await storeReleased();
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)
@@ -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
- The `task-media-ended` event and the `completing` phase are likewise non-terminal. A replacement
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",