@xema/omni-protocol 0.1.28 → 0.1.29

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/README.md CHANGED
@@ -15,6 +15,7 @@ system.
15
15
  | `src/index.ts` | The TypeScript declarations. |
16
16
  | `src/validation.ts` | Runtime validators Omni applies to adapter output. |
17
17
  | `src/testing.ts` | Conformance helpers an adapter runs against its own test state. |
18
+ | `tests/` | One test file per source module, plus the guards over the guide and the repository's own text. |
18
19
 
19
20
  ## Entry points
20
21
 
package/dist/index.d.ts CHANGED
@@ -607,6 +607,13 @@ export type Task<C extends Channel = Channel> = {
607
607
  /** The person or entity on the other end of this task. Who the task is with; `contacts` on the snapshot is the directory. */
608
608
  party?: Contact;
609
609
  phase: TaskPhase;
610
+ /**
611
+ * How this offer is accepted, stated on the pending task rather than the offer so a reconnect
612
+ * snapshot says it too: an offer the host never received is not accepted on the person's behalf
613
+ * for want of a word. Required while `pending` when Omni said it may auto-accept
614
+ * (`autoAcceptTasks: true`), forbidden when it said not, and absent past `pending`.
615
+ */
616
+ acceptance?: AcceptanceMode;
610
617
  /** The identifier an agent reads back to a customer, where the provider has one. */
611
618
  reference?: string;
612
619
  attributes?: TaskAttribute[];
@@ -1077,7 +1084,6 @@ export type ProviderEvent<C extends Channel = Channel> = {
1077
1084
  } | {
1078
1085
  type: "task-offered";
1079
1086
  task: Task<C>;
1080
- acceptanceMode?: AcceptanceMode;
1081
1087
  allocationExpiresAt?: IsoTimestamp;
1082
1088
  preparationEndsAt?: IsoTimestamp;
1083
1089
  } | {
package/dist/testing.d.ts CHANGED
@@ -7,7 +7,7 @@ export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation }
7
7
  * rules -- each optional part of a task, each optional part of the break state and roster, each
8
8
  * declared contribution, and each event type.
9
9
  */
10
- declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.consultation", "task.lead", "task.assisting", "task.media", "task.dispositions", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities", "team.policies"];
10
+ declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.consultation", "task.lead", "task.assisting", "task.media", "task.acceptance", "task.dispositions", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities", "team.policies"];
11
11
  export type ContractSubject = (typeof STATE_SUBJECTS)[number] | `event.${ProviderEvent["type"]}`;
12
12
  export interface AdapterContractResult {
13
13
  events: ProviderEventEnvelope[];
package/dist/testing.js CHANGED
@@ -16,6 +16,7 @@ const STATE_SUBJECTS = [
16
16
  "task.lead",
17
17
  "task.assisting",
18
18
  "task.media",
19
+ "task.acceptance",
19
20
  "task.dispositions",
20
21
  "task.destinations",
21
22
  "task.custom",
@@ -61,6 +62,8 @@ function observeTask(value, seen) {
61
62
  seen.add("task.assisting");
62
63
  if (value.media !== undefined)
63
64
  seen.add("task.media");
65
+ if (value.acceptance !== undefined)
66
+ seen.add("task.acceptance");
64
67
  const capabilities = isRecord(value.capabilities) ? value.capabilities : {};
65
68
  if (isRecord(capabilities.dispositions))
66
69
  seen.add("task.dispositions");
@@ -15,6 +15,8 @@ export interface TaskValidationContext {
15
15
  channel: string;
16
16
  /** The level ids in force, from the manifest. The defaults when absent. */
17
17
  levels?: readonly string[];
18
+ /** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether a pending task states its `acceptance`. */
19
+ autoAcceptTasks?: boolean;
18
20
  }
19
21
  export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
20
22
  /**
@@ -36,7 +38,7 @@ export interface ReaderContext {
36
38
  levels?: readonly string[];
37
39
  /** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
38
40
  loginId?: string;
39
- /** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether `task-offered` carries an `acceptanceMode`. */
41
+ /** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether a pending task states its `acceptance`. */
40
42
  autoAcceptTasks?: boolean;
41
43
  }
42
44
  export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
@@ -729,6 +729,20 @@ function validateTaskInto(task, context, path, into) {
729
729
  into.filled(task.title, "task.title", `${path}.title`, "a task needs a title");
730
730
  into.filled(task.taskType, "task.taskType", `${path}.taskType`, "a task needs a task type");
731
731
  into.oneOf(task.phase, TASK_PHASES, "task.phase", `${path}.phase`);
732
+ // Acceptance is the offer's word, carried on the pending task so a snapshot can say it: it
733
+ // travels exactly when Omni said tasks may be auto-accepted, and only while the task is pending.
734
+ if (task.acceptance !== undefined) {
735
+ into.oneOf(task.acceptance, ACCEPTANCE_MODES, "task.acceptance", `${path}.acceptance`);
736
+ if (task.phase !== "pending") {
737
+ into.add("task.acceptance.unexpected", `${path}.acceptance`, "acceptance is an offer's word; a task past pending has been accepted");
738
+ }
739
+ else if (context.autoAcceptTasks === false) {
740
+ into.add("task.acceptance.unexpected", `${path}.acceptance`, "autoAcceptTasks is off, so every task requires agent acceptance and a pending task carries no acceptance");
741
+ }
742
+ }
743
+ else if (task.phase === "pending" && context.autoAcceptTasks === true) {
744
+ into.add("task.acceptance.required", `${path}.acceptance`, "autoAcceptTasks is on, so a pending task states how it is accepted");
745
+ }
732
746
  into.oneOf(task.completionMode, COMPLETION_MODES, "task.completionMode", `${path}.completionMode`);
733
747
  // The allowance is coupled to the mode: a provider that will complete the task itself is going
734
748
  // to act on the allowance, so it must state one; a provider waiting for `complete` may omit it
@@ -1009,7 +1023,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1009
1023
  const seen = new Set();
1010
1024
  let assisting;
1011
1025
  snapshot.tasks.forEach((task, index) => {
1012
- validateTaskInto(task, { channel, levels }, `${path}.tasks[${index}]`, into);
1026
+ validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks }, `${path}.tasks[${index}]`, into);
1013
1027
  // A lead assists one call at a time.
1014
1028
  if (isPlainObject(task) && task.assisting !== undefined) {
1015
1029
  if (assisting !== undefined)
@@ -1196,28 +1210,18 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1196
1210
  validateBreakState(event.break, `${at}.break`, into);
1197
1211
  break;
1198
1212
  case "task-offered":
1199
- validateTaskInto(event.task, { channel, levels }, `${at}.task`, into);
1213
+ validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks }, `${at}.task`, into);
1200
1214
  // An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
1201
1215
  if (isPlainObject(event.task) && typeof event.task.phase === "string") {
1202
1216
  into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
1203
1217
  }
1204
- if (event.acceptanceMode !== undefined) {
1205
- into.oneOf(event.acceptanceMode, ACCEPTANCE_MODES, "event.taskOffered.acceptanceMode", `${at}.acceptanceMode`);
1206
- }
1207
- // The mode travels exactly when Omni said tasks may be auto-accepted.
1208
- if (context.autoAcceptTasks === true) {
1209
- into.require(event.acceptanceMode !== undefined, "event.taskOffered.acceptanceMode.required", `${at}.acceptanceMode`, "autoAcceptTasks is on, so task-offered carries an acceptanceMode");
1210
- }
1211
- else if (context.autoAcceptTasks === false) {
1212
- into.require(event.acceptanceMode === undefined, "event.taskOffered.acceptanceMode.unexpected", `${at}.acceptanceMode`, "autoAcceptTasks is off, so every task requires agent acceptance and task-offered carries no acceptanceMode");
1213
- }
1214
1218
  for (const field of ["allocationExpiresAt", "preparationEndsAt"]) {
1215
1219
  if (event[field] !== undefined)
1216
1220
  into.timestamp(event[field], `event.taskOffered.${field}`, `${at}.${field}`);
1217
1221
  }
1218
1222
  break;
1219
1223
  case "task-updated":
1220
- validateTaskInto(event.task, { channel, levels }, `${at}.task`, into);
1224
+ validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks }, `${at}.task`, into);
1221
1225
  break;
1222
1226
  case "task-media-started":
1223
1227
  into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
package/guide.md CHANGED
@@ -604,6 +604,7 @@ type Task<C extends Channel = Channel> = {
604
604
  browsers: TaskBrowser[];
605
605
  party?: Contact;
606
606
  phase: TaskPhase;
607
+ acceptance?: AcceptanceMode;
607
608
  reference?: string;
608
609
  attributes?: TaskAttribute[];
609
610
  handlingHistory?: TaskHandlingStep[];
@@ -941,7 +942,6 @@ type ProviderEvent =
941
942
  | {
942
943
  type: "task-offered";
943
944
  task: Task;
944
- acceptanceMode?: AcceptanceMode;
945
945
  allocationExpiresAt?: IsoTimestamp;
946
946
  preparationEndsAt?: IsoTimestamp;
947
947
  }
@@ -1830,7 +1830,7 @@ Creates one live provider connection for the signed-in agent.
1830
1830
  | --- | --- |
1831
1831
  | `protocolVersion` | Version negotiated before authentication. Fixed for this login. |
1832
1832
  | `loginId` | Omni-generated identity for this login. It is the same value passed as `AuthenticationContext.loginId`, so an adapter can correlate this connection with the session that authenticated it. Stable across transport reconnects and changed only by a new login. |
1833
- | `autoAcceptTasks` | Agent provisioning policy relayed to the provider at login. Treated as `true` when omitted. When `true`, `task-offered` carries an `acceptanceMode`; when `false`, every task requires agent acceptance. |
1833
+ | `autoAcceptTasks` | Agent provisioning policy relayed to the provider at login. Treated as `true` when omitted. When `true`, a pending task states its `acceptance`; when `false`, every task requires agent acceptance. Fixed for this connection, like everything else here: the provider states or omits `acceptance` by the value it was sent, and Omni validates by that same value, not by a policy that has since moved — a change reaches the provider through a fresh `connect()`. |
1834
1834
  | `host` | The host's report of the agent's station — devices, permissions, network — to consult before declaring the agent ready to the platform, and on every change. See **The host reports, the adapter decides**. |
1835
1835
  | `signal` | Optional cancellation signal. Stop startup promptly when aborted and do not begin new work. |
1836
1836
  | `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
@@ -1974,13 +1974,15 @@ deliver again once it has gone quiet. Hold the capacity and deliver when work ar
1974
1974
  **4. A provider offers a task.** The provider emits `task-offered` within the stated capacity.
1975
1975
 
1976
1976
  **5. Omni decides how the task is accepted.** When `autoAcceptTasks` is `false`, every task requires
1977
- agent acceptance. When it is `true`, the event's `acceptanceMode` states the provider's
1977
+ agent acceptance. When it is `true`, the pending task's `acceptance` states the provider's
1978
1978
  intent.
1979
1979
 
1980
1980
  ### Acceptance modes
1981
1981
 
1982
1982
  During login, Omni sends the agent's `autoAcceptTasks` value to the provider. When it is `true`, the
1983
- provider includes an acceptance directive with each allocation:
1983
+ provider states `acceptance` on each pending task — on the task rather than the offer, so a
1984
+ reconnect snapshot says it too and an offer the host never received is not accepted on the
1985
+ person's behalf for want of a word:
1984
1986
 
1985
1987
  | Directive | Contract |
1986
1988
  | --- | --- |
@@ -1988,7 +1990,7 @@ provider includes an acceptance directive with each allocation:
1988
1990
  | `consent` | The provider requires the person's explicit consent: Omni presents **Accept** and waits, whatever its own policy would have done. A host that declares `guarantees.personConsent` promises exactly this; a provider checks it before offering work only a person may take. |
1989
1991
  | `automatic` | Omni accepts immediately without agent interaction. |
1990
1992
 
1991
- When Omni sent `autoAcceptTasks: false`, the provider omits `acceptanceMode` and every task
1993
+ When Omni sent `autoAcceptTasks: false`, the provider omits `acceptance` and every task
1992
1994
  requires agent acceptance. The two are never confused on the wire: `consent` is always the
1993
1995
  provider's requirement, stated on a wire where Omni was willing to accept for the agent; Omni's own
1994
1996
  no-auto-accept policy puts no word on the wire at all — the field is absent, and the **Accept**
@@ -1998,19 +2000,19 @@ press is Omni's doing, not the provider's.
1998
2000
  gone ready is telling the deployment they are working. Requiring a press before every contact is
1999
2001
  the exception a provisioning file asks for, not the state it falls into when a flag is missing.
2000
2002
 
2001
- Nothing is given away by that default. `acceptanceMode` is the provider's own control and outranks
2003
+ Nothing is given away by that default. `acceptance` is the provider's own control and outranks
2002
2004
  it: `consent` puts the decision back in the agent's hands for any task where it
2003
2005
  belongs, whatever the host was configured with.
2004
2006
 
2005
2007
  An automatically accepted task still arrives through `task-offered`.
2006
2008
 
2007
- Agent-initiated work arrives through `task-offered` with
2008
- `acceptanceMode: "automatic"`.
2009
+ Agent-initiated work arrives through `task-offered` with the task's
2010
+ `acceptance: "automatic"`.
2009
2011
 
2010
2012
  ### Pending
2011
2013
 
2012
2014
  A task in the `pending` phase has been **offered to the agent and not yet accepted**. Omni applies
2013
- `autoAcceptTasks` and the allocation's `acceptanceMode` to decide whether acceptance is
2015
+ `autoAcceptTasks` and the task's `acceptance` to decide whether acceptance is
2014
2016
  automatic or requires the agent. A provider that requires automatic acceptance still emits
2015
2017
  `task-offered`; it does not introduce new work as `in-progress`.
2016
2018
 
@@ -2019,8 +2021,7 @@ declare const task: Task;
2019
2021
 
2020
2022
  const allocation = {
2021
2023
  type: "task-offered",
2022
- task,
2023
- acceptanceMode: "consent",
2024
+ task: { ...task, phase: "pending", acceptance: "consent" },
2024
2025
  allocationExpiresAt: "2026-08-25T10:41:07.000Z",
2025
2026
  preparationEndsAt: "2026-08-25T10:40:37.000Z",
2026
2027
  } satisfies Extract<ProviderEvent, { type: "task-offered" }>;
@@ -2083,6 +2084,7 @@ time. Runtime conformance checks also require the task channel to match its prov
2083
2084
  | `party` | The person or entity on the other end of this task, as a `Contact`: often a name and one address; a withheld caller ID may leave nothing to send at all. Optional. The party is who the task is *with*; `contacts` is the directory. |
2084
2085
  | `phase` | Current canonical task phase: `pending`, `confirmed`, `preparing`, `in-progress`, `paused`, or `completing`. |
2085
2086
  | `media` | Voice only. The task's real-time audio as the provider holds it: `started` while audio is attached, `ended` once it ended, omitted while none is. The provider's word — see **`task-media-started`**. |
2087
+ | `acceptance` | How this offer is accepted — `no-preference`, `consent`, or `automatic` — stated on the pending task so a reconnect snapshot says it too. Required while `pending` when `autoAcceptTasks` was `true`, forbidden when it was `false`, and absent past `pending`. See **Acceptance modes**. |
2086
2088
  | `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
2087
2089
  | `completionMode` | `agent-command` waits for the channel's `complete` command; `provider-automatic` completes without one. |
2088
2090
  | `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,12 +2136,12 @@ The canonical task transitions are:
2134
2136
  | Any phase | Provider emits `task-ended` | Removed |
2135
2137
 
2136
2138
  Allocation, acceptance, and progress are distinct. Acceptance follows `autoAcceptTasks` and the
2137
- allocation's `acceptanceMode`, moving the task from `pending` to `confirmed`. The provider reports
2139
+ task's `acceptance`, moving the task from `pending` to `confirmed`. The provider reports
2138
2140
  subsequent transitions to `preparing` or `in-progress`; Omni does not infer them from the acceptance
2139
2141
  command.
2140
2142
 
2141
2143
  **A task is never its audio.** A voice task is the allocation: the call is offered when it is
2142
- routed to the agent and accepted as `acceptanceMode` dictates, and its presence and phase follow
2144
+ routed to the agent and accepted as its `acceptance` dictates, and its presence and phase follow
2143
2145
  the provider's reports about the work — never the audio. Wherever audio moves — an offer, a hold, a
2144
2146
  consult, a conference leg joining or leaving, a transfer, a callback — the media follows
2145
2147
  separately, arriving on `task-media-started`, attaching through `openMedia` and ending with
@@ -3548,8 +3550,8 @@ multi-provider break**.
3548
3550
  ### `task-offered`
3549
3551
 
3550
3552
  Offers a task to Omni without a separate offer acknowledgement. An offer does not accept
3551
- the task: when its phase is `pending`, Omni applies `autoAcceptTasks` and the event's
3552
- `acceptanceMode`. `task-offered` must not introduce a task as `in-progress`; only a reconnect or
3553
+ the task: when its phase is `pending`, Omni applies `autoAcceptTasks` and the task's
3554
+ `acceptance`. `task-offered` must not introduce a task as `in-progress`; only a reconnect or
3553
3555
  resync snapshot may report work already in progress. The provider should include the task in later
3554
3556
  snapshots until it ends.
3555
3557
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "scripts": {
31
31
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
32
32
  "build": "pnpm run clean && tsc -p tsconfig.json",
33
- "test": "tsc -p tsconfig.type-tests.json && vitest run"
33
+ "test": "tsc -p tsconfig.test.json && vitest run"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^22.20.1",