@xema/omni-protocol 0.1.125 → 0.1.127

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
@@ -281,6 +281,13 @@ export interface AuthenticationContext {
281
281
  * later as `ConnectContext.timeZone`.
282
282
  */
283
283
  timeZone: TimeZone;
284
+ /**
285
+ * The languages the agent reads, as BCP 47 tags, most preferred first, from the host's own
286
+ * setting. Never empty: the host states at least one. The provider words what the agent reads
287
+ * -- failure and transport messages -- by it, looked up as `localizedText()` does. The same
288
+ * value arrives later as `ConnectContext.languages`.
289
+ */
290
+ languages: readonly LanguageTag[];
284
291
  /**
285
292
  * How this login hears its calls, chosen by the host from the manifest's `phones`. Required for
286
293
  * a voice provider and absent for any other. The same value arrives later as `ConnectContext.phone`.
@@ -680,6 +687,8 @@ export type ConnectContext = {
680
687
  * identity; a roaming agent corrects it by signing in.
681
688
  */
682
689
  timeZone: TimeZone;
690
+ /** The same value passed as `AuthenticationContext.languages`: what the agent reads, most preferred first. */
691
+ languages: readonly LanguageTag[];
683
692
  /** Where the adapter keeps this login's operational state across a reload of the host. */
684
693
  store: LoginStore;
685
694
  /** The same value passed as `AuthenticationContext.phone`: how this login hears its calls. Narrowed by the arms below. */
@@ -1836,7 +1845,7 @@ export type ProviderEvent<C extends Channel = Channel> = {
1836
1845
  type: "transport-status";
1837
1846
  status: "error";
1838
1847
  recovery: TransportRecovery;
1839
- message?: string;
1848
+ message: string;
1840
1849
  } | {
1841
1850
  type: "break-state";
1842
1851
  break: BreakState;
package/dist/testing.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
2
- import { assertNoViolations, validateAuthenticationState, validateBreakTransition, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateUserDetails, observeRules, ruleTested, validateHostMute, validateHostRecording, validateLoginStore, validateHistoryReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
2
+ import { assertNoViolations, validateAuthenticationState, validateBreakTransition, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateUserDetails, observeRules, ruleTested, validateHostMute, validateHostRecording, validateLoginStore, validateHistoryReport, validateManifest, validateResult, validateSnapshot, validateTask, validateLanguages, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
3
3
  export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
4
4
  /**
5
5
  * A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
@@ -345,6 +345,8 @@ export async function testAdapter(adapter, context, options = {}) {
345
345
  violations.push(...validateHostGuarantees(context.host.guarantees, "context.host.guarantees", adapter.manifest.channel === "voice" ? context.phone : undefined));
346
346
  // The zone is the host's to state, so a context without one is a host that cannot exist.
347
347
  violations.push(...validateTimeZone(context.timeZone, "context.timeZone"));
348
+ // So are the languages: what the agent reads is stated, never assumed.
349
+ violations.push(...validateLanguages(context.languages, "context.languages"));
348
350
  // How the agent hears the call decides what the host owns: on a softphone the host has the
349
351
  // audio and reports it; on a hardphone, or off voice, there is none for it to report.
350
352
  violations.push(...validatePhone(context.phone, adapter.manifest, "context.phone"));
@@ -30,6 +30,8 @@ export declare function validateProviderTimeCheckRequest(value: unknown, path?:
30
30
  /** Checks response shape/correlation, not source-clock accuracy, round-trip delay or freshness. */
31
31
  export declare function validateProviderTimeCheckResult(value: unknown, request: unknown, loginId: string, path?: string): ProtocolViolation[];
32
32
  export declare function validateManifest(manifest: unknown, path?: string): ProtocolViolation[];
33
+ /** The agent's languages, as a host states them on both contexts: BCP 47 tags, most preferred first, at least one. */
34
+ export declare function validateLanguages(value: unknown, path?: string): ProtocolViolation[];
33
35
  /** The customer's own words, as `Snapshot.labels` and `labels-updated` carry them. */
34
36
  export declare function validateLabels(value: unknown, path?: string): ProtocolViolation[];
35
37
  export interface TaskValidationContext {
@@ -563,6 +563,25 @@ export function validateManifest(manifest, path = "manifest") {
563
563
  }
564
564
  /** Language tags as BCP 47 writes them: a language, then subtags of up to eight letters or digits. */
565
565
  const LANGUAGE_TAG = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/;
566
+ /** The agent's languages, as a host states them on both contexts: BCP 47 tags, most preferred first, at least one. */
567
+ export function validateLanguages(value, path = "context.languages") {
568
+ const into = new Collector();
569
+ if (!Array.isArray(value) || value.length === 0) {
570
+ into.add("context.languages", path, "the host states the languages the agent reads: at least one BCP 47 tag, most preferred first");
571
+ return into.violations;
572
+ }
573
+ const seen = new Set();
574
+ value.forEach((tag, index) => {
575
+ if (typeof tag !== "string" || !LANGUAGE_TAG.test(tag)) {
576
+ into.add("context.languages.tag", `${path}[${index}]`, `${describeValue(tag)} is not a BCP 47 language tag`);
577
+ return;
578
+ }
579
+ const key = tag.toLowerCase();
580
+ into.require(!seen.has(key), "context.languages.unique", `${path}[${index}]`, `${tag} is listed twice`);
581
+ seen.add(key);
582
+ });
583
+ return into.violations;
584
+ }
566
585
  /** The customer's own words, as `Snapshot.labels` and `labels-updated` carry them. */
567
586
  export function validateLabels(value, path = "labels") {
568
587
  const into = new Collector();
@@ -2070,7 +2089,12 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
2070
2089
  else {
2071
2090
  into.require(event.recovery === undefined, "event.transportStatus.recovery.unexpected", `${at}.recovery`, "recovery goes with an error; nothing needs reviving here");
2072
2091
  }
2073
- if (event.message !== undefined) {
2092
+ if (event.status === "error") {
2093
+ if (into.require(event.message !== undefined, "event.transportStatus.message.required", `${at}.message`, "an error says why, in words the agent sees")) {
2094
+ into.filled(event.message, "event.transportStatus.message", `${at}.message`, "a message must not be empty");
2095
+ }
2096
+ }
2097
+ else if (event.message !== undefined) {
2074
2098
  into.filled(event.message, "event.transportStatus.message", `${at}.message`, "a message must not be empty when present");
2075
2099
  }
2076
2100
  break;
package/guide.md CHANGED
@@ -289,6 +289,7 @@ type AuthenticationContext = {
289
289
  protocolVersion: number;
290
290
  loginId: string;
291
291
  timeZone: TimeZone;
292
+ languages: readonly LanguageTag[];
292
293
  phone?: Phone;
293
294
  redirectUri?: string;
294
295
  secrets: SecretStore;
@@ -423,6 +424,7 @@ type ConnectContext = {
423
424
  loginId: string;
424
425
  autoAcceptTasks: boolean;
425
426
  timeZone: TimeZone;
427
+ languages: readonly LanguageTag[];
426
428
  store: LoginStore;
427
429
  phone?: Phone;
428
430
  host: Host;
@@ -1215,7 +1217,7 @@ type TransportRecovery = "reconnect" | "reauthenticate" | "displaced";
1215
1217
  type ProviderEvent =
1216
1218
  | { type: "snapshot"; reason: "reconnected" | "provider-requested"; snapshot: Snapshot }
1217
1219
  | { type: "transport-status"; status: "connecting" | "active"; message?: string }
1218
- | { type: "transport-status"; status: "error"; recovery: TransportRecovery; message?: string }
1220
+ | { type: "transport-status"; status: "error"; recovery: TransportRecovery; message: string }
1219
1221
  | { type: "break-state"; break: BreakState }
1220
1222
  | {
1221
1223
  type: "task-offered";
@@ -2051,6 +2053,7 @@ Creates the provider-scoped authentication session.
2051
2053
  | `protocolVersion` | The negotiated version, fixed for this login. |
2052
2054
  | `loginId` | Omni-generated identity for this login. The same value Omni later passes as `ConnectContext.loginId`, and how an adapter ties a connection back to the session that authenticated it. |
2053
2055
  | `timeZone` | The zone the agent's day is reckoned in, as an IANA name from the agent application's clock, stated before any identity exists so the first `authenticated` state already carries it. The same value Omni later passes as `ConnectContext.timeZone`. See **The agent's day**. |
2056
+ | `languages` | The languages the agent reads, as BCP 47 tags, most preferred first, from the agent application's own setting: `["hi-IN", "en"]`. Never empty (`context.languages`), each a well-formed tag (`context.languages.tag`), none twice (`context.languages.unique`). The same value Omni later passes as `ConnectContext.languages`. See **The agent's languages**. |
2054
2057
  | `phone` | How this login hears its calls, chosen by the agent application from the manifest's `phones`: required for a voice provider, absent for any other. The same value Omni later passes as `ConnectContext.phone`. See **How the agent hears the call** in `guide/phone.md`. |
2055
2058
  | `secrets` | Omni-provided `SecretStore`, scoped to this provider's manifest id. |
2056
2059
  | `signal` | Optional cancellation signal. |
@@ -2075,7 +2078,7 @@ reports later changes.
2075
2078
  | `authenticated` | A usable session exists. Includes the provider identity, the login's `capabilities`, and optional token expiry time. |
2076
2079
  | `refreshing` | The adapter is refreshing its session. Existing provider identity and capabilities remain available, unchanged: a change to either is published as `authenticated`. |
2077
2080
  | `expired` | The session's time ran out, or its refresh is failing. It may include an identity and typed failure. The agent signs in again on the same login, and the workspace stays. |
2078
- | `terminated` | The session was ended from outside this agent computer: by a supervisor, by the agent on another screen, or by the platform itself. `by` says who, a person by `UserId` or `provider` where no person was behind it (`authentication.terminated.by`), and `failure` says why and is never retryable (`authentication.terminated.failure`, `.retryable`). It is never reported as `expired`, which would hide who ended it, nor as `signed-out`, which is the agent's own act. |
2081
+ | `terminated` | The session was ended from outside this agent computer: by a supervisor, by the agent on another screen, or by the platform itself. `by` says who, a person by `UserId` or `provider` where no person was behind it (`authentication.terminated.by`), and `failure` says why and is never retryable (`authentication.terminated.failure`, `.retryable`). It is never reported as `expired`, which would hide who ended it, nor as `signed-out`, which is the agent's own act. The session's ending is the whole message: the agent application disposes the connection with `disconnect()` and shows `failure.message` on the sign-in, and the adapter reports no transport `error` beside it -- `reauthenticate` would say this session is restored, and it is not. |
2079
2082
 
2080
2083
  Omni calls `connect()` only after authentication reaches `authenticated`. Token refresh remains
2081
2084
  adapter-owned; the adapter publishes `refreshing`, followed by `authenticated` or `expired`.
@@ -2095,6 +2098,14 @@ calls `connect()` afresh, as for `reconnect` -- see **`transport-status`**. What
2095
2098
  replaces the login" describes is a new `AuthenticationSession` under a new `loginId`, after
2096
2099
  `signed-out`.
2097
2100
 
2101
+ **A sign-in the platform no longer allows is ended, not restored.** A role changed mid-shift can
2102
+ take away what the login was built on -- an agent made a supervisor may no longer have a phone, and
2103
+ the platform refuses to renew the softphone. Retrying cannot help and the same session cannot carry
2104
+ on, so the adapter deletes the secrets and publishes `terminated`, `by: "provider"`, with a failure
2105
+ in the agent's words: `{ code: "phone-not-allowed", message: "Your sign-in no longer allows a phone.
2106
+ Sign in again.", retryable: false }`. The agent application disposes the connection and shows that
2107
+ message on the sign-in.
2108
+
2098
2109
  ### What the login may do
2099
2110
 
2100
2111
  `capabilities` declares provider actions available to this login rather than to one task: whether
@@ -2242,6 +2253,9 @@ short bound the adapter states, and then given up: a provider that cannot be rea
2242
2253
  token on the agent's machine. If it was not told, `signOut()` still deletes and still reaches
2243
2254
  `signed-out`, and answers `failed`, so the agent application can show that a session may remain at
2244
2255
  the provider. `close()` stops authentication-state observation but does not sign the agent out.
2256
+ The agent application ends the connection itself, with `disconnect()`: the sign-out is its own act,
2257
+ so the adapter reports no transport `error` for it, and a connection closed by the platform as the
2258
+ sign-out lands is not news to anyone.
2245
2259
 
2246
2260
  **The final moments are the agent application's to finish.** Its own steps before signing out --
2247
2261
  stopping capacity, putting tasks down -- may be retried within a short bound it states, and then
@@ -2312,6 +2326,7 @@ Creates one live provider connection for the signed-in agent.
2312
2326
  | `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. |
2313
2327
  | `autoAcceptTasks` | Agent local policy policy relayed to the provider at login, stated by the agent application on every connection and never assumed from its absence. 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()`. |
2314
2328
  | `timeZone` | The same value passed as `AuthenticationContext.timeZone`. The provider stores it on the agent and carries it on the identity. See **The agent's day**. |
2329
+ | `languages` | The same value passed as `AuthenticationContext.languages`. See **The agent's languages**. |
2315
2330
  | `phone` | The same value passed as `AuthenticationContext.phone`: how this login hears its calls. The type ties `host.mute` to it: a `softphone` login's agent application states what its Mute does, and a hardphone or conversation login's agent application cannot, so the omission is a compile error rather than a live seat's discovery. See **The station is the agent application's** in `guide/phone.md`. |
2316
2331
  | `store` | The login's operational store, kept by the agent application for the life of the login, across a reload of the agent application, and cleared at sign-out: where an adapter that composes a record keeps what its platform cannot hold for it, such as the interaction legs an agent application reported. Three functions, by key. Never for anything sensitive, which is `AuthenticationContext.secrets`, a store an agent application may clear aggressively. **A task's keys carry its assignment id and go with the task**: a key written about a task names the assignment id in the key, and is deleted before the task's end is published, so that nothing of a closed task survives its ending and nothing is left for whatever comes next -- a record with legs the agent application never reported against it. A login-scoped key carries no assignment id and outlives any task. The harness requires the store of every connection (`store.shape`, `store.get`, `.set`, `.delete`), watches the one it hands over, and names a task's key still held after `task-ended` (`test.store.retained`) or written about the task after its end -- a persist hung off a timer that saw the task as it was (`test.store.late`). The ordinary late write is the agent application's, not the adapter's: an agent application reports a leg without waiting for the answer, so an unmute can follow `complete` by a tick, and the adapter answers a report about a task that has ended `failed` with `omni.assignment-not-found` and writes nothing -- the agent application ends its own open legs at the task's end, so such a report is the agent application's error to see, and an agent application reads every `recordStep` answer and awaits the one for the leg it closes at a task's end, the only moment a refusal is expected, since a report nobody waits for is an error nobody can see; an adapter that names no task in its keys gets no cleanup check, which is a gap rather than a pass, never an exemption: the obligation is that nothing of a closed task survives its ending, and an adapter that keeps every open task in one login-scoped value owes exactly that inside the value, where the harness cannot look. One key per task, named for it, is the shape the harness can hold, and the shape to reach for. The store lists nothing, so an adapter that needs to find its tasks keeps a login-scoped index of ids beside them; at the task's end it deletes the body first and reindexes after, since a crash between the two then leaves an index naming a task with no body, which a reader skips, where the other order leaves a body for a task that has ended, which is the hazard itself. A reader of the index tolerates an id with no body as an ending that was underway, not as corruption. |
2317
2332
  | `host` | The agent application'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 agent application reports, the adapter decides** in `guide/phone.md`. |
@@ -2349,6 +2364,17 @@ and a lead reading a colleague's day would still get the wrong one. What proves
2349
2364
  the run never sent: a colleague's `User` from `getUserDetails()` carrying theirs. A provider's own
2350
2365
  tests are where that is shown, with a second agent whose zone arrived through another session.
2351
2366
 
2367
+ ### The agent's languages
2368
+
2369
+ Everything the provider writes for the agent to read -- a failure's `message`, a transport
2370
+ `message`, a refusal's reason -- is written for one person, and the agent application says which
2371
+ languages that person reads: `languages`, BCP 47 tags in order of preference, on both contexts and
2372
+ the same value in each. It is stated, never assumed, as the zone is: never empty, because an empty
2373
+ list would be a default nobody chose, and the provider's own default is what the agent application
2374
+ falls back to only when no tag it was given matches. The provider picks a language the way
2375
+ `localizedText()` does -- each tag whole, then without its last subtag, in the agent's order -- and
2376
+ words its messages in the first it has. The harness holds the host to the shape (`validateLanguages`).
2377
+
2352
2378
  ### Who the agent is
2353
2379
 
2354
2380
  `ConnectContext` names no agent. The adapter already knows who is connected: it authenticated them,
@@ -2687,7 +2713,7 @@ safe for the agent to see.
2687
2713
  | --- | --- |
2688
2714
  | `connecting` | No usable transport right now, and the adapter expects to recover on its own. Nobody needs to act. Startup and every reconnect pass through this value. |
2689
2715
  | `active` | The transport is up and the provider is serving this session. It is the only value under which work arrives. |
2690
- | `error` | The adapter cannot serve the session and is not simply mid-reconnect. Say why in `message`, and say what revives it in `recovery` — required here, forbidden on any other status. It is not terminal: an adapter that recovers on its own still reports `connecting` and then `active`, and one that cannot is revived as `recovery` says. |
2716
+ | `error` | The adapter cannot serve the session and is not simply mid-reconnect. Say why in `message` (`event.transportStatus.message.required`), and say what revives it in `recovery` — both required here, and `recovery` forbidden on any other status. It is not terminal: an adapter that recovers on its own still reports `connecting` and then `active`, and one that cannot is revived as `recovery` says. |
2691
2717
 
2692
2718
  **An error names its recovery, and the agent application acts on that word.** The adapter knows why its session
2693
2719
  died; the agent application knows how to run a login. `recovery` joins the two:
@@ -2697,7 +2723,10 @@ died; the agent application knows how to run a login. `recovery` joins the two:
2697
2723
  `connect()` again on the same login — same `loginId` — and the fresh connect snapshot
2698
2724
  re-establishes state exactly as a reconnect snapshot does. `connect()` is once per
2699
2725
  `Connection`, not once per login.
2700
- - **`reauthenticate`** — the session under the login died: a token rejected, a remote logout. Omni
2726
+ - **`reauthenticate`** — the session under the login lapsed and the same login can be restored: a
2727
+ token rejected, published as `expired`. A session ended from outside -- a remote logout, a
2728
+ permission withdrawn -- is `terminated`, which is the whole message and gets no transport `error`
2729
+ beside it (see **Authentication state**). Omni
2701
2730
  runs the authentication flow first, on the same session and `loginId`, never through
2702
2731
  `signed-out`; the authentication session decides whether stored material refreshes it silently or
2703
2732
  the agent must act, exactly as at sign-in. Once the state returns to `authenticated`, Omni
@@ -2740,7 +2769,10 @@ adapter that leaves a stale `active` in place is telling Omni to keep waiting fo
2740
2769
  come — see **Liveness**.
2741
2770
 
2742
2771
  **The status is seen, not merely known.** An agent application renders the transport's status where the agent
2743
- works — `connecting` from the moment it is reported, `error` with what revives it — and never a
2772
+ works — `connecting` from the moment it is reported, `error` with what revives it — and shows the
2773
+ provider's `message` as written, never a generic "disconnected" in its place: the provider knows why,
2774
+ and the words are what tell the agent whether to wait, sign in again or call somebody. The same holds
2775
+ for the `failure.message` of an `expired` or `terminated` session. Never a
2744
2776
  healthy workspace over a transport that cannot serve it: an agent talking on an agent computer whose transport
2745
2777
  died was an incident, and it happened because the agent computer knew and did not say. The same obligation
2746
2778
  covers a `diagnostic`: shown where the agent works, and counted.
@@ -3065,6 +3097,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
3065
3097
  | `validateLabels(value, path?)` | The customer's words on their own, as `labels-updated` and `Snapshot.labels` carry them: renames of the protocol's phases and steps only, and every word one text per well-formed language tag (`labels.*`). |
3066
3098
  | `validatePhoneState(value, path?, context?)` | The phone as the platform sees it, on its own as `phone-updated` and `Snapshot.phone` carry it: its status, its one active channel, its mute where a hardphone's is observed (`phone.*`). |
3067
3099
  | `validateHostMute(mute, softphone)` | What the agent application's Mute does, stated on a softphone login and nowhere else: `stream` or `station` (`host.mute`), required where the agent application holds a microphone (`host.mute.required`) and refused where it does not (`host.mute.unexpected`). The harness holds `ConnectContext.host.mute` to it. |
3100
+ | `validateLanguages(languages)` | The agent's languages a host states on both contexts: a non-empty list (`context.languages`) of well-formed BCP 47 tags (`context.languages.tag`), none twice ignoring case (`context.languages.unique`). The harness holds `ConnectContext.languages` to it. |
3068
3101
  | `validateLoginStore(store)` | The login's store the agent application hands every connection: an object with `get`, `set` and `delete` (`store.shape`, `store.get`, `.set`, `.delete`). The harness holds `ConnectContext.store` to it. |
3069
3102
  | `validateCapacity(capacity)` | What the agent application states as capacity: a whole number of zero or more (`capacity.count`), zero being agent application-stopped. The harness states one on connect and two, one and zero after the test, each answered `applied`, and holds any offer to the count in force (`stream.taskOffered.overCapacity`). |
3070
3103
  | `validateAuthenticationResult(result, method)` | What `start()` or `complete()` answered: a challenge or a rejection, a login or a rejection. A rejection's failure is held to its rules -- an `omni.` code the contract lists, and `omni.phone-not-permitted` never retryable, since the agent's station is configuration. `validateAuthenticationFailure(failure)` is the same check on a failure alone. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.125",
3
+ "version": "0.1.127",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",