@xema/omni-protocol 0.1.1 → 0.1.3

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
@@ -290,6 +290,8 @@ export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ?
290
290
  mute?: true;
291
291
  hold?: true;
292
292
  agentDisconnect?: true;
293
+ /** Reach the party again while `completing`; the task returns to `in-progress`. */
294
+ callback?: true;
293
295
  blindTransfer?: true | DestinationDirectory;
294
296
  conference?: true | DestinationDirectory;
295
297
  recording?: true;
@@ -375,7 +377,22 @@ export interface TaskHandlingStep {
375
377
  */
376
378
  by?: UserId;
377
379
  }
378
- export interface Task<C extends Channel = Channel> {
380
+ /**
381
+ * How the task ends, and how long after handling the provider allows for it.
382
+ *
383
+ * The allowance is coupled to the mode. Under `provider-automatic` the provider acts on it, so it
384
+ * is required. Under `agent-command` the provider will not complete the task itself, so it may
385
+ * omit the allowance to say it imposes no deadline -- and Omni then counts nothing down. Omitted
386
+ * and `0` are different claims: no deadline to see, and a deadline of now.
387
+ */
388
+ export type TaskCompletion = {
389
+ completionMode: "agent-command";
390
+ completionAllowance?: DurationSeconds;
391
+ } | {
392
+ completionMode: "provider-automatic";
393
+ completionAllowance: DurationSeconds;
394
+ };
395
+ export type Task<C extends Channel = Channel> = {
379
396
  id: TaskId;
380
397
  title: string;
381
398
  channel: C;
@@ -387,11 +404,9 @@ export interface Task<C extends Channel = Channel> {
387
404
  phase: TaskPhase;
388
405
  /** The identifier an agent reads back to a customer, where the provider has one. */
389
406
  reference?: string;
390
- completionMode: CompletionMode;
391
- completionAllowance: DurationSeconds;
392
407
  attributes?: TaskAttribute[];
393
408
  handlingHistory?: TaskHandlingStep[];
394
- }
409
+ } & TaskCompletion;
395
410
  /** What the provider wants of Omni's acceptance policy for one offer. */
396
411
  export type AcceptanceMode = "no-preference" | "require-agent-acceptance" | "require-automatic-acceptance";
397
412
  export type TaskOutcome = {
@@ -413,7 +428,7 @@ export type TaskOutcome = {
413
428
  failure: ProtocolFailure;
414
429
  };
415
430
  export declare const TASK_COMMAND_NAMES: {
416
- readonly voice: readonly ["answer", "decline", "start-call", "mute", "hold", "resume", "disconnect", "transfer", "conference", "recording", "complete"];
431
+ readonly voice: readonly ["answer", "decline", "start-call", "mute", "hold", "resume", "disconnect", "callback", "transfer", "conference", "recording", "complete"];
417
432
  readonly chat: readonly ["accept", "reject", "pause", "resume", "complete"];
418
433
  readonly email: readonly ["accept", "reject", "complete"];
419
434
  };
@@ -437,6 +452,10 @@ export type VoiceTaskCommand = {
437
452
  type: "resume";
438
453
  } | {
439
454
  type: "disconnect";
455
+ }
456
+ /** Issuable only in `completing`, under the `callback` capability. Carries no destination. */
457
+ | {
458
+ type: "callback";
440
459
  } | {
441
460
  type: "transfer";
442
461
  destination: string;
package/dist/index.js CHANGED
@@ -55,7 +55,7 @@ export function isAllowedBrowserUrl(url) {
55
55
  // ---------------------------------------------------------------------------
56
56
  export const TASK_COMMAND_NAMES = {
57
57
  voice: ["answer", "decline", "start-call", "mute", "hold", "resume", "disconnect",
58
- "transfer", "conference", "recording", "complete"],
58
+ "callback", "transfer", "conference", "recording", "complete"],
59
59
  chat: ["accept", "reject", "pause", "resume", "complete"],
60
60
  email: ["accept", "reject", "complete"],
61
61
  };
package/dist/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Connection, type Snapshot, type Task, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type DialRequest, type TaskCommandRequest } from "./index.js";
1
+ import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Connection, type Snapshot, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type DialRequest, type TaskCommandRequest } from "./index.js";
2
2
  import { type ProtocolViolation } from "./validation.js";
3
3
  export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation } from "./validation.js";
4
4
  export interface AdapterContractResult {
@@ -39,16 +39,23 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
39
39
  * There is no `denied` approval: a refusal returns the agent to `not-requested`, because a
40
40
  * pending request nobody is coming to decide is worse than none. So the scenario is a request
41
41
  * that goes back to not-requested, and a later one that is granted.
42
+ *
43
+ * A request shows as `awaiting-decision` where a person decides, and as `granted` at once where
44
+ * the provider decides alone; either is the request being made, and the rule is the same for
45
+ * both.
42
46
  */
43
47
  export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
44
48
  /** Verifies that retrying one dial command cannot place a second call. */
45
49
  export declare function assertDialIdempotency(connection: Pick<Connection, "dial">, request: DialRequest): Promise<void>;
46
50
  /**
47
51
  * Validates the deadline derived from media end and the task's fixed wrap allowance.
48
- * `toleranceMs` absorbs scheduler jitter in a real implementation; pass 0 to demand
49
- * an exact match.
52
+ *
53
+ * A task with no allowance has no deadline, so `observedDeadline` must then be `undefined`: a
54
+ * host counting down what the provider left open is the violation, and so is a host counting
55
+ * nothing down when the provider set a clock. `toleranceMs` absorbs scheduler jitter in a real
56
+ * implementation; pass 0 to demand an exact match.
50
57
  */
51
- export declare function assertWrapTimeout(task: Pick<Task, "completionAllowance">, mediaEndedAt: string, observedDeadline: string, toleranceMs?: number): void;
58
+ export declare function assertWrapTimeout(task: Pick<TaskCompletion, "completionMode" | "completionAllowance">, mediaEndedAt: string, observedDeadline: string | undefined, toleranceMs?: number): void;
52
59
  /** One browser in one task of one provider. `providerId` is `Manifest.id`, never `displayName`. */
53
60
  export type BrowserIsolationScenario = BrowserSessionKeyInput;
54
61
  /** Validates whether two task-browser definitions should share one browser session. */
package/dist/testing.js CHANGED
@@ -209,9 +209,13 @@ export function assertReconnectWithMissedAssignments(before, reconnect, missedTa
209
209
  * There is no `denied` approval: a refusal returns the agent to `not-requested`, because a
210
210
  * pending request nobody is coming to decide is worse than none. So the scenario is a request
211
211
  * that goes back to not-requested, and a later one that is granted.
212
+ *
213
+ * A request shows as `awaiting-decision` where a person decides, and as `granted` at once where
214
+ * the provider decides alone; either is the request being made, and the rule is the same for
215
+ * both.
212
216
  */
213
217
  export function assertDeniedAndRetriedBreak(approvals) {
214
- const asked = approvals.indexOf("awaiting-decision");
218
+ const asked = approvals.findIndex(approval => approval === "awaiting-decision" || approval === "granted");
215
219
  if (asked < 0)
216
220
  throw new Error("Break retry scenario requires an initial request");
217
221
  const refused = approvals.indexOf("not-requested", asked + 1);
@@ -245,10 +249,22 @@ export async function assertDialIdempotency(connection, request) {
245
249
  }
246
250
  /**
247
251
  * Validates the deadline derived from media end and the task's fixed wrap allowance.
248
- * `toleranceMs` absorbs scheduler jitter in a real implementation; pass 0 to demand
249
- * an exact match.
252
+ *
253
+ * A task with no allowance has no deadline, so `observedDeadline` must then be `undefined`: a
254
+ * host counting down what the provider left open is the violation, and so is a host counting
255
+ * nothing down when the provider set a clock. `toleranceMs` absorbs scheduler jitter in a real
256
+ * implementation; pass 0 to demand an exact match.
250
257
  */
251
258
  export function assertWrapTimeout(task, mediaEndedAt, observedDeadline, toleranceMs = 1_000) {
259
+ if (task.completionAllowance === undefined) {
260
+ if (observedDeadline !== undefined) {
261
+ throw new Error(`Wrap deadline mismatch: the task states no allowance, so there is no deadline, received ${observedDeadline}`);
262
+ }
263
+ return;
264
+ }
265
+ if (observedDeadline === undefined) {
266
+ throw new Error(`Wrap deadline mismatch: the task allows ${task.completionAllowance}s, but no deadline was observed`);
267
+ }
252
268
  const ended = Date.parse(mediaEndedAt);
253
269
  const deadline = Date.parse(observedDeadline);
254
270
  if (Number.isNaN(ended) || Number.isNaN(deadline))
@@ -79,7 +79,7 @@ const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
79
79
  const TASK_CAPABILITIES = {
80
80
  voice: membersOf({
81
81
  browsers: true, dispositions: true, custom: true, decline: true, mute: true, hold: true,
82
- agentDisconnect: true, blindTransfer: true, conference: true, recording: true,
82
+ agentDisconnect: true, callback: true, blindTransfer: true, conference: true, recording: true,
83
83
  }),
84
84
  chat: membersOf({ browsers: true, dispositions: true, custom: true, reject: true, hold: true }),
85
85
  email: membersOf({ browsers: true, dispositions: true, custom: true, reject: true }),
@@ -517,7 +517,15 @@ function validateTaskInto(task, context, path, into) {
517
517
  into.filled(task.taskType, "task.taskType", `${path}.taskType`, "a task needs a task type");
518
518
  into.oneOf(task.phase, TASK_PHASES, "task.phase", `${path}.phase`);
519
519
  into.oneOf(task.completionMode, COMPLETION_MODES, "task.completionMode", `${path}.completionMode`);
520
- into.require(isDurationSeconds(task.completionAllowance), "task.completionAllowance", `${path}.completionAllowance`, "completionAllowance must be a whole number of seconds, zero or more");
520
+ // The allowance is coupled to the mode: a provider that will complete the task itself is going
521
+ // to act on the allowance, so it must state one; a provider waiting for `complete` may omit it
522
+ // to say it imposes no deadline. Present, it is a duration either way.
523
+ if (task.completionAllowance === undefined) {
524
+ into.require(task.completionMode !== "provider-automatic", "task.completionAllowance.required", `${path}.completionAllowance`, "provider-automatic completion needs an allowance to act on");
525
+ }
526
+ else {
527
+ into.require(isDurationSeconds(task.completionAllowance), "task.completionAllowance", `${path}.completionAllowance`, "completionAllowance must be a whole number of seconds, zero or more, or omitted under agent-command");
528
+ }
521
529
  // The channel is fixed per provider by its manifest, so a task claiming another one is a
522
530
  // task Omni would render with the wrong controls.
523
531
  into.require(task.channel === context.channel, "task.channel", `${path}.channel`, `a ${context.channel} provider may not publish a ${String(task.channel)} task`);
package/guide.md CHANGED
@@ -335,6 +335,7 @@ type TaskCapabilities<C extends Channel = Channel> =
335
335
  mute?: true;
336
336
  hold?: true;
337
337
  agentDisconnect?: true;
338
+ callback?: true;
338
339
  blindTransfer?: true | DestinationDirectory;
339
340
  conference?: true | DestinationDirectory;
340
341
  recording?: true;
@@ -416,6 +417,10 @@ type TaskHandlingStep = {
416
417
  by?: UserId;
417
418
  };
418
419
 
420
+ type TaskCompletion =
421
+ | { completionMode: "agent-command"; completionAllowance?: DurationSeconds }
422
+ | { completionMode: "provider-automatic"; completionAllowance: DurationSeconds };
423
+
419
424
  type Task<C extends Channel = Channel> = {
420
425
  id: TaskId;
421
426
  title: string;
@@ -426,11 +431,9 @@ type Task<C extends Channel = Channel> = {
426
431
  contact?: Contact;
427
432
  phase: TaskPhase;
428
433
  reference?: string;
429
- completionMode: CompletionMode;
430
- completionAllowance: DurationSeconds;
431
434
  attributes?: TaskAttribute[];
432
435
  handlingHistory?: TaskHandlingStep[];
433
- };
436
+ } & TaskCompletion;
434
437
 
435
438
  type AcceptanceMode =
436
439
  | "no-preference"
@@ -457,6 +460,7 @@ const TASK_COMMAND_NAMES = {
457
460
  "hold",
458
461
  "resume",
459
462
  "disconnect",
463
+ "callback",
460
464
  "transfer",
461
465
  "conference",
462
466
  "recording",
@@ -479,6 +483,7 @@ type VoiceTaskCommand =
479
483
  | { type: "hold" }
480
484
  | { type: "resume" }
481
485
  | { type: "disconnect" }
486
+ | { type: "callback" }
482
487
  | { type: "transfer"; destination: string }
483
488
  | { type: "conference"; participant: string; action: "add" | "remove" }
484
489
  | { type: "recording"; action: "start" | "pause" | "resume" | "stop" }
@@ -1616,7 +1621,7 @@ time. Runtime conformance checks also require the task channel to match its prov
1616
1621
  | `phase` | Current canonical task phase: `pending`, `confirmed`, `preparing`, `in-progress`, `paused`, or `completing`. |
1617
1622
  | `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
1618
1623
  | `completionMode` | `agent-command` waits for the channel's `complete` command; `provider-automatic` completes without one. |
1619
- | `completionAllowance` | Fixed time allowed to complete the task after primary handling ends. For real-time media, it begins after `task-media-ended`. |
1624
+ | `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. |
1620
1625
  | `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. |
1621
1626
  | `handlingHistory` | Optional ordered handling history for this currently open task. It is live task data, not a permanent archive. |
1622
1627
 
@@ -1656,6 +1661,7 @@ The canonical task transitions are:
1656
1661
  | `in-progress` | Provider or agent pauses the task | `paused` |
1657
1662
  | `paused` | Provider or agent resumes the task | `in-progress` |
1658
1663
  | `in-progress` or `paused` | Contact handling ends and follow-up work remains | `completing` |
1664
+ | `completing` | Agent calls the party back (`callback`) | `in-progress` |
1659
1665
  | Any phase | Provider emits `task-ended` | Removed |
1660
1666
 
1661
1667
  Allocation, acceptance, and progress are distinct. Acceptance follows `autoAcceptTasks` and the
@@ -1690,9 +1696,61 @@ In this example, the agent has two minutes after sending the email to add notes,
1690
1696
  disposition, and complete the task.
1691
1697
 
1692
1698
  `0` means completion may happen immediately. With `provider-automatic`, the provider may complete
1693
- without waiting for a command; with `agent-command`, it still waits for `complete`. There is no
1694
- value meaning "unlimited": a provider that does not want a deadline keeps the task `in-progress` or
1695
- `paused` and moves it to `completing` only when the clock should start.
1699
+ without waiting for a command; with `agent-command`, it still waits for `complete`.
1700
+
1701
+ There is no value meaning "unlimited", because a number that is not a duration would be read as
1702
+ one. A provider that imposes no deadline says so by **omitting** `completionAllowance`, which
1703
+ only `agent-command` permits: the provider will not complete the task itself, so there is nothing
1704
+ for a deadline to trigger, and Omni counts nothing down. **Omitted and empty are different
1705
+ claims** applies -- omitted says there is no deadline to see, where `0` says the deadline is now.
1706
+ Under `provider-automatic` the field is required, because the provider is going to act on it.
1707
+
1708
+ ```ts
1709
+ const untimedWrap = {
1710
+ completionMode: "agent-command",
1711
+ } satisfies Pick<Task<"voice">, "completionMode" | "completionAllowance">;
1712
+ ```
1713
+
1714
+ Here the customer has hung up, `task-media-ended` has been sent on time, the task is `completing`,
1715
+ and the agent takes as long as the work needs. Moving the task to `completing` late to avoid a
1716
+ deadline is not an alternative on a media channel: the clock starts at a real event, and delaying
1717
+ that event would falsify the phase and everything timed from it.
1718
+
1719
+ #### Calling back during completion
1720
+
1721
+ A task that declares `callback` lets the agent reach the party again while the task is
1722
+ `completing` -- to finish what the call left unfinished, on the same task rather than a new one.
1723
+ Omni issues `{ type: "callback" }`; it is issuable only in `completing`, and only where the
1724
+ capability is declared. The provider knows who the party is; the command carries no destination.
1725
+
1726
+ On `applied` the provider is placing the call and the task returns to `in-progress`: the agent is
1727
+ working again, and the completion allowance is **discarded, not paused**. From there the call is
1728
+ reported as any call is -- `paused`, `in-progress`, and when its media ends, `task-media-ended`
1729
+ again, which starts a fresh allowance from that instant. A party who does not answer is a call
1730
+ whose media ended: the task returns to `completing` through the same event and the clock starts
1731
+ again from there. At no point is an agent dialling against a deadline.
1732
+
1733
+ **The control exists only while there is a window to use it in.** Under `agent-command` the task
1734
+ stays `completing` until the agent completes it, so the window is open for as long as they need.
1735
+ Under `provider-automatic` the window is the allowance -- and with `completionAllowance: 0` there
1736
+ is none: the provider disposes the task at provider end, and Omni does not offer Call back, whatever
1737
+ the task declares. A capability names a control that can be used; on a task with no `completing`
1738
+ window it cannot, and declaring it there changes nothing.
1739
+
1740
+ ```ts
1741
+ const callbackCapable = {
1742
+ channel: "voice",
1743
+ capabilities: { hold: true, callback: true, dispositions: true },
1744
+ phase: "completing",
1745
+ completionMode: "provider-automatic",
1746
+ completionAllowance: 30,
1747
+ } satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "completionMode" | "completionAllowance">;
1748
+ ```
1749
+
1750
+ With ten seconds of the thirty left, the agent presses Call back: `execute({ command: { type:
1751
+ "callback" } })` returns `applied`, the task is `in-progress`, and the thirty seconds are gone.
1752
+ The second call ends: `task-media-ended`, the task is `completing`, and a new thirty seconds runs
1753
+ from that instant.
1696
1754
 
1697
1755
  ```ts
1698
1756
  const immediateProviderCompletion = {
@@ -1887,6 +1945,7 @@ const taskCapabilities = {
1887
1945
  | `mute` | Primary toggle: Mute | Omni may mute and unmute the agent's outbound audio. |
1888
1946
  | `hold` | Primary toggle: Hold | Omni may issue voice-task `hold` and `resume` commands. |
1889
1947
  | `agentDisconnect` | Primary button: Disconnect | Omni may disconnect real-time media without disposing the task. |
1948
+ | `callback` | Completing-task button: Call back | Omni may have the provider call the task's party back while the task is `completing`, returning it to `in-progress` on the same task. Not offered where there is no `completing` window: `provider-automatic` with a zero allowance disposes at provider end. See **Calling back during completion**. |
1890
1949
  | `blindTransfer` | Secondary menu item: Blind transfer | Omni may transfer the caller directly to a destination. |
1891
1950
  | `conference` | Secondary button: Conference | Omni may add or remove participants from the active call. |
1892
1951
  | `recording` | Overflow menu item: Recording | Omni may expose start, pause, resume, and stop recording controls. |
@@ -2601,6 +2660,7 @@ declared:
2601
2660
  | `decline`, `reject` | The channel's decline or reject capability, **and** Omni provisioning permitting rejection. |
2602
2661
  | `start-call` | The `preparing` phase. It starts the contact a preview gave the agent time to read, so the phase is the gate and there is no capability. |
2603
2662
  | `complete` | `completionMode: "agent-command"`. The `dispositions` capability decides whether a code travels with the command, never whether the command exists — a task Omni cannot complete never ends. |
2663
+ | `callback` | The `callback` capability **and** the `completing` phase. It exists to reach the party again after the call, so it has no meaning while the call is up. |
2604
2664
  | Everything else | Its own named capability. |
2605
2665
 
2606
2666
  Declining or rejecting a pending offer ends it without accepting or completing it. The provider
@@ -2666,7 +2726,8 @@ They are published as `OMNI_FAILURE_CODES`.
2666
2726
  #### Provider instants are read against a provider clock
2667
2727
 
2668
2728
  Every deadline in this contract is a provider instant that Omni counts down: `allocationExpiresAt`,
2669
- `preparationEndsAt`, and the wrap deadline of `task-media-ended` plus `completionAllowance`.
2729
+ `preparationEndsAt`, and the wrap deadline of `task-media-ended` plus `completionAllowance` where
2730
+ one is stated.
2670
2731
  Comparing those against the host clock is wrong by whatever the two machines disagree by, and the
2671
2732
  damaging direction is early — **Accept** withdrawn from an offer still ringing, a wrap timer
2672
2733
  expiring before the agent has finished.
@@ -2913,7 +2974,7 @@ cannot be established from TypeScript structure alone.
2913
2974
  | `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
2914
2975
  | `assertCommandIdempotency(connection, request)` | Retrying a task command does not repeat its side effect. |
2915
2976
  | `assertDialIdempotency(connection, request)` | Retrying a dial command does not place another call. |
2916
- | `assertWrapTimeout(task, mediaEndedAt, deadline, toleranceMs?)` | The wrap deadline equals media end plus the task allowance, within a tolerance that defaults to 1000ms. |
2977
+ | `assertWrapTimeout(task, mediaEndedAt, deadline, toleranceMs?)` | The wrap deadline equals media end plus the task allowance, within a tolerance that defaults to 1000ms; a task with no allowance has no deadline, and one observed is the violation. |
2917
2978
  | `assertBrowserIsolationAndReuse(left, right, expected)` | Browser reuse follows only the declared isolation scheme. |
2918
2979
  | `assertNoBrowserSessionKeyCollisions(scenarios)` | No two distinct scenarios derive the same session key. Feed it adversarial names. |
2919
2980
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",