@xema/omni-protocol 0.1.122 → 0.1.124

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/GLOSSARY.md CHANGED
@@ -100,7 +100,7 @@ hands only through routing or a lead's take-over.
100
100
 
101
101
  | Term | In plain words | What others call it |
102
102
  | --- | --- | --- |
103
- | **History** | The steps a call went through before and with this agent: arrived, queued, answered, held, handed over. | *Contact trace record* (Amazon Connect), *conversation details* or *segments* (Genesys Cloud), *call detail record*. |
103
+ | **History** | The steps a call went through before and with this agent: arrived, self-service, queued, answered, held, handed over. | *Contact trace record* (Amazon Connect), *conversation details* or *segments* (Genesys Cloud), *call detail record*. |
104
104
  | **Recording** | A recording of one side of the call, the customer's or the agent's. | *Call recording*, *screen and voice recording*. |
105
105
  | **Paused for sensitive details** | Recording stops while card or account details are given. | *Secure pause* (Genesys Cloud), *PCI pause*. |
106
106
 
package/dist/index.d.ts CHANGED
@@ -872,7 +872,7 @@ export type TaskAttribute = TaskAttributeBase & ({
872
872
  type: "timestamp";
873
873
  at: IsoTimestamp;
874
874
  });
875
- export type HistoryStep = "arrived" | "queued" | "offered" | "answered" | "held" | "muted" | "transferred" | "conferenced" | "unanswered" | "callback-requested";
875
+ export type HistoryStep = "arrived" | "self-service" | "queued" | "offered" | "answered" | "held" | "muted" | "transferred" | "conferenced" | "unanswered" | "callback-requested";
876
876
  /**
877
877
  * The call record: the steps that brought the task here, one entry per occurrence and oldest
878
878
  * first, and what they add up to before this agent -- stated by the provider from its own record,
@@ -907,7 +907,7 @@ export interface TaskHistoryStep {
907
907
  */
908
908
  seconds?: DurationSeconds;
909
909
  /**
910
- * Who took part. Absent on `arrived` and `queued`, where nobody does; absent on `callback-requested`, where the
910
+ * Who took part. Absent on `arrived`, `self-service` and `queued`, where nobody does; absent on `callback-requested`, where the
911
911
  * caller chose it in the queue, and present there when an agent arranged it; absent on any other
912
912
  * step means the provider could not attribute it, which is a different claim and a legitimate one.
913
913
  */
@@ -2053,6 +2053,7 @@ export declare const DEFAULT_TASK_TYPE_PRESENTATION: {
2053
2053
  };
2054
2054
  export declare const DEFAULT_HISTORY_STEP_LABELS: {
2055
2055
  readonly arrived: "Incoming call";
2056
+ readonly "self-service": "Self-service";
2056
2057
  readonly queued: "Queued";
2057
2058
  readonly offered: "Offered";
2058
2059
  readonly answered: "Answered";
@@ -2086,14 +2087,28 @@ export declare const assignmentKey: (providerId: string, assignmentId: Assignmen
2086
2087
  */
2087
2088
  export declare const userKey: (providerId: string, userId: UserId) => string;
2088
2089
  /**
2089
- * Every history step somebody from the provider takes part in. `arrived` and `queued` are the ones
2090
- * nobody does, and `callback-requested` is the caller's choice unless an agent arranged it, so none
2090
+ * Every history step somebody from the provider takes part in. `arrived`, `self-service` and
2091
+ * `queued` are the ones nobody does, and `callback-requested` is the caller's choice unless an agent arranged it, so none
2091
2092
  * of them is here.
2092
2093
  */
2093
2094
  export declare const HISTORY_STEPS_WITH_A_PERSON: readonly ["offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered"];
2094
2095
  /** The steps a dial writes, and so the only ones that carry a `dialId` and a `destinationId`. */
2095
2096
  export declare const HISTORY_STEPS_THAT_DIAL: readonly ["transferred", "conferenced", "unanswered"];
2096
2097
  export declare const historyStepDials: (step: HistoryStep) => boolean;
2098
+ /**
2099
+ * Consecutive entries of one step, shown as one span. The record keeps an entry per occurrence --
2100
+ * two holds are counted as two -- and this is how the agent sees them: once, from the first
2101
+ * entry's `at`, lasting the sum of their `seconds` when every entry states one, and with no
2102
+ * duration when any does not, never a sum that silently leaves a running leg out.
2103
+ */
2104
+ export interface HistorySpan {
2105
+ step: HistoryStep;
2106
+ at: IsoTimestamp;
2107
+ seconds?: DurationSeconds;
2108
+ entries: TaskHistoryStep[];
2109
+ }
2110
+ /** Groups a record's consecutive same-step entries into spans, oldest first. */
2111
+ export declare function historySpans(steps: readonly TaskHistoryStep[]): HistorySpan[];
2097
2112
  /** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
2098
2113
  export declare function historyStepExpectsAPerson(step: HistoryStep): boolean;
2099
2114
  export interface BrowserSessionKeyInput {
package/dist/index.js CHANGED
@@ -146,7 +146,7 @@ export const DEFAULT_TASK_TYPE_PRESENTATION = {
146
146
  email: { singular: "Email", plural: "Emails", referenceLabel: "Email ID" },
147
147
  };
148
148
  export const DEFAULT_HISTORY_STEP_LABELS = {
149
- arrived: "Incoming call", queued: "Queued", offered: "Offered", answered: "Answered", held: "On hold",
149
+ arrived: "Incoming call", "self-service": "Self-service", queued: "Queued", offered: "Offered", answered: "Answered", held: "On hold",
150
150
  muted: "Muted", transferred: "Transferred", conferenced: "Conferenced", unanswered: "Unanswered",
151
151
  "callback-requested": "Callback requested",
152
152
  };
@@ -188,8 +188,8 @@ export const assignmentKey = (providerId, assignmentId) => `${encodeURIComponent
188
188
  */
189
189
  export const userKey = (providerId, userId) => `${encodeURIComponent(providerId)}:${encodeURIComponent(userId)}`;
190
190
  /**
191
- * Every history step somebody from the provider takes part in. `arrived` and `queued` are the ones
192
- * nobody does, and `callback-requested` is the caller's choice unless an agent arranged it, so none
191
+ * Every history step somebody from the provider takes part in. `arrived`, `self-service` and
192
+ * `queued` are the ones nobody does, and `callback-requested` is the caller's choice unless an agent arranged it, so none
193
193
  * of them is here.
194
194
  */
195
195
  export const HISTORY_STEPS_WITH_A_PERSON = [
@@ -201,6 +201,23 @@ export const historyStepDials = (step) => HISTORY_STEPS_THAT_DIAL.includes(step)
201
201
  // Pinned both ways: a step added to `HistoryStep` has to be placed here, and a step listed here
202
202
  // has to exist there. `satisfies` on the list covers the second; this statement covers the first.
203
203
  true;
204
+ /** Groups a record's consecutive same-step entries into spans, oldest first. */
205
+ export function historySpans(steps) {
206
+ const spans = [];
207
+ for (const entry of steps) {
208
+ const last = spans[spans.length - 1];
209
+ if (last !== undefined && last.step === entry.step)
210
+ last.entries.push(entry);
211
+ else
212
+ spans.push({ step: entry.step, at: entry.at, entries: [entry] });
213
+ }
214
+ for (const span of spans) {
215
+ if (span.entries.every(entry => entry.seconds !== undefined)) {
216
+ span.seconds = span.entries.reduce((total, entry) => total + entry.seconds, 0);
217
+ }
218
+ }
219
+ return spans;
220
+ }
204
221
  /** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
205
222
  export function historyStepExpectsAPerson(step) {
206
223
  return HISTORY_STEPS_WITH_A_PERSON.includes(step);
@@ -58,7 +58,7 @@ const TEAM_AVAILABILITIES = membersOf({
58
58
  ready: true, "on-task": true, "on-break": true, elsewhere: true, "signed-out": true,
59
59
  });
60
60
  const HISTORY_STEPS = membersOf({
61
- arrived: true, queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
61
+ arrived: true, "self-service": true, queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
62
62
  "callback-requested": true,
63
63
  });
64
64
  const CUSTOM_UI_CONTROLS = membersOf({ button: true, toggle: true, "menu-item": true });
@@ -995,8 +995,8 @@ function validateHistory(value, path, into, task = {}) {
995
995
  }
996
996
  if (entry.by !== undefined) {
997
997
  into.require(isUserId(entry.by), "task.history.by", `${at}.by`, "by must be a non-empty user id; omit it when the person cannot be identified");
998
- // On `arrived` and `queued` nobody takes part, so there is nothing to name.
999
- into.require(entry.step !== "arrived" && entry.step !== "queued", "task.history.by.unexpected", `${at}.by`, `${describeValue(entry.step)} names nobody`);
998
+ // On `arrived`, `self-service` and `queued` nobody takes part, so there is nothing to name.
999
+ into.require(entry.step !== "arrived" && entry.step !== "self-service" && entry.step !== "queued", "task.history.by.unexpected", `${at}.by`, `${describeValue(entry.step)} names nobody`);
1000
1000
  }
1001
1001
  // A step that dialled says where to and, when a host placed it, which dial; no other step dialled.
1002
1002
  const dialled = typeof entry.step === "string" && HISTORY_STEPS_THAT_DIAL.includes(entry.step);
package/guide/agent.md CHANGED
@@ -409,7 +409,8 @@ first, and what they add up to before this agent:
409
409
  ```ts
410
410
  history: {
411
411
  steps: [
412
- { step: "arrived", at: "2026-08-21T00:58:40Z", seconds: 20 },
412
+ { step: "arrived", at: "2026-08-21T00:58:40Z", seconds: 2 },
413
+ { step: "self-service", at: "2026-08-21T00:58:42Z", seconds: 18 },
413
414
  { step: "queued", at: "2026-08-21T00:59:00Z", seconds: 41 },
414
415
  { step: "answered", at: "2026-08-21T00:59:41Z", seconds: 312, by: "a-17" },
415
416
  { step: "held", at: "2026-08-21T01:02:10Z", seconds: 35, by: "a-17" },
@@ -430,7 +431,7 @@ something else, which will arrive under its own name and must not be folded in h
430
431
 
431
432
  It rides in the snapshot and is replaced whole like everything else there.
432
433
 
433
- Steps are `arrived`, `queued`, `offered`, `answered`, `held`, `muted`, `transferred`, `conferenced`,
434
+ Steps are `arrived`, `self-service`, `queued`, `offered`, `answered`, `held`, `muted`, `transferred`, `conferenced`,
434
435
  `unanswered`, `callback-requested`, and each is defined on `TaskHistoryStep`.
435
436
 
436
437
  **The record starts where the call started.** `arrived` is the instant the call reached the
@@ -442,6 +443,17 @@ arrive omits it rather than guessing an instant. Omni shows it as *Incoming call
442
443
  Amazon Connect keeps an initiation time, Genesys Cloud a conversation start and NICE CXone a contact
443
444
  start, separate from the queue.
444
445
 
446
+ **Time the platform handles the customer before a queue is `self-service`.** The caller pressing
447
+ through a menu, talking to a voice bot, hearing an announcement, or waiting while the platform
448
+ looks them up; a chat customer with a bot before a person takes over: one step for all of them, on
449
+ every channel. Genesys Cloud and NICE CXone report IVR and bot time as self-service; here it also
450
+ takes in the routing time Genesys Cloud keeps as a separate system segment, so a provider that
451
+ measures the gap before each queue without knowing what played in it reports it here rather than
452
+ nowhere. It is therefore not evidence that the customer used a menu. Nobody from the provider takes
453
+ part, so it has no `by`. `at` is when it began and `seconds`, once it ended, how long it lasted; a
454
+ customer routed again after a transfer has a second entry, one per visit, as a second queue is a
455
+ second `queued`. A customer that words it *IVR* for its agents does so in `labels.historySteps`.
456
+
445
457
  **A callback carries the call it comes back to.** When a caller asks to be called back, or an agent
446
458
  arranges it, the provider writes a `callback-requested` step: `by` names the agent who arranged it, and
447
459
  is absent when the caller chose it in the queue. The callback reaches an agent as a new task, and that
@@ -474,7 +486,13 @@ provider closed it. One publisher of the closed leg, and no order between them t
474
486
  hold, at the entry's `at` plus its `seconds`, and nothing is lost by not naming it twice. The same goes for every step: two mutes are two
475
487
  `muted` entries, and a call that joined two queues -- a menu's, then this one -- has two `queued`
476
488
  entries, one per join. Order is enforced: an entry earlier than the one before it is refused
477
- (`task.history.order`). **Intervals between steps are the agent application's to subtract**, never a
489
+ (`task.history.order`).
490
+
491
+ **Shown as spans.** The record keeps one entry per occurrence, because leads count them — two holds
492
+ are two holds, the way Amazon Connect counts holds per contact, and two queues say which queue the
493
+ call waited in. The agent sees consecutive entries of one step as one span: `historySpans(steps)`
494
+ groups them, as Genesys Cloud keeps segments apart in its record and joins them in the agent's
495
+ timeline. A provider never merges entries to get that look. **Intervals between steps are the agent application's to subtract**, never a
478
496
  total the provider adds: *time to offer*, from the call joining its queue to the agent's screen lighting
479
497
  up, is the `offered` entry's `at` minus the last `queued` entry's, and the ring is outside it.
480
498
  `queueSeconds` keeps the industry's meaning -- the whole wait until somebody answered -- and is not
@@ -551,7 +569,7 @@ Four rules a provider has to keep:
551
569
  wrongly; carrying the name here would also copy it into every task and leave it to go stale.
552
570
 
553
571
  **An absent `by` means different things on different steps, and both are legitimate.** On
554
- `arrived` and `queued` nobody takes part, so there is nothing to name. On every other step somebody did — see
572
+ `arrived`, `self-service` and `queued` nobody takes part, so there is nothing to name. On every other step somebody did — see
555
573
  `HISTORY_STEPS_WITH_A_PERSON` and `historyStepExpectsAPerson()` — so an absent `by` there says
556
574
  *this was handled and the provider cannot say by whom*. The one step that is never unattributed is
557
575
  `muted`: the agent application has exactly one agent and the provider knows who, so `by` is required there
@@ -564,7 +582,7 @@ worse than one saying plainly it could not attribute a leg — and far better th
564
582
  nothing because a single leg could not be named.
565
583
 
566
584
  An agent application must render the two differently. Showing an unattributed `answered` the same way as
567
- `queued` or `arrived` tells the agent nobody was involved, which is not what was said. Omni renders it as
585
+ `queued`, `self-service` or `arrived` tells the agent nobody was involved, which is not what was said. Omni renders it as
568
586
  *"not recorded"* in the place the name would go.
569
587
 
570
588
  Omit `history` entirely when the provider cannot observe the steps. Empty `steps` is a
package/guide.md CHANGED
@@ -696,6 +696,7 @@ type TaskAttribute = TaskAttributeBase & (
696
696
 
697
697
  type HistoryStep =
698
698
  | "arrived"
699
+ | "self-service"
699
700
  | "queued"
700
701
  | "offered"
701
702
  | "answered"
@@ -724,6 +725,13 @@ type TaskHistoryStep = {
724
725
  mutedBy?: MutedBy;
725
726
  };
726
727
 
728
+ type HistorySpan = {
729
+ step: HistoryStep;
730
+ at: IsoTimestamp;
731
+ seconds?: DurationSeconds;
732
+ entries: TaskHistoryStep[];
733
+ };
734
+
727
735
  type TaskCompletion =
728
736
  | { completionMode: "agent-command"; wrapAllowance?: DurationSeconds }
729
737
  | { completionMode: "provider-automatic"; wrapAllowance: DurationSeconds };
@@ -1362,7 +1370,7 @@ const OMNI_FAILURE_CODES = [
1362
1370
  type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
1363
1371
  ```
1364
1372
 
1365
- `HISTORY_STEPS_WITH_A_PERSON` is every `HistoryStep` except `arrived` and `queued`, which nobody
1373
+ `HISTORY_STEPS_WITH_A_PERSON` is every `HistoryStep` except `arrived`, `self-service` and `queued`, which nobody
1366
1374
  takes part in, and `callback-requested`, which the caller may choose alone. `historyStepExpectsAPerson()` tests membership.
1367
1375
 
1368
1376
  ### Failure and validation
@@ -1445,6 +1453,7 @@ agent's languages:
1445
1453
  ```ts
1446
1454
  const DEFAULT_HISTORY_STEP_LABELS = {
1447
1455
  arrived: "Incoming call",
1456
+ "self-service": "Self-service",
1448
1457
  queued: "Queued",
1449
1458
  offered: "Offered",
1450
1459
  answered: "Answered",
@@ -2988,6 +2997,14 @@ Whether two logins declare the same capabilities, field by field — key order a
2988
2997
  `team: {}` distinct from `team` absent. It is the comparison an adapter makes before republishing
2989
2998
  `authenticated`, and the one `testAdapter` holds `refreshing` to.
2990
2999
 
3000
+ ### `historySpans(steps)`
3001
+
3002
+ Groups a record's consecutive entries of one step into a `HistorySpan` each — `step`, the first
3003
+ entry's `at`, `seconds` summed when every entry states one and absent when any does not, and the
3004
+ `entries` themselves — so every agent application shows *Self-service · 30s* once where the
3005
+ record has two self-service entries in a row. Only neighbours are grouped: a hold, an answer and
3006
+ another hold are three spans. See **Shown as spans** in `guide/agent.md`.
3007
+
2991
3008
  ## Runtime validation
2992
3009
 
2993
3010
  Structural rules in this document are executable through the runtime validators Omni applies to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.122",
3
+ "version": "0.1.124",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",