@xema/omni-protocol 0.1.53 → 0.1.55

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
@@ -1394,7 +1394,7 @@ export interface ProviderEventEnvelope<C extends Channel = Channel> {
1394
1394
  occurredAt: IsoTimestamp;
1395
1395
  event: ProviderEvent<C>;
1396
1396
  }
1397
- export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.task-not-found", "omni.destination-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed"];
1397
+ export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.task-not-found", "omni.destination-not-permitted", "omni.phone-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed"];
1398
1398
  export type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
1399
1399
  export interface Connection<C extends Channel = Channel> {
1400
1400
  snapshot(): Snapshot<C> | Promise<Snapshot<C>>;
package/dist/index.js CHANGED
@@ -102,6 +102,7 @@ export const OMNI_FAILURE_CODES = [
102
102
  "omni.capability-not-enabled",
103
103
  "omni.task-not-found",
104
104
  "omni.destination-not-permitted",
105
+ "omni.phone-not-permitted",
105
106
  "omni.rate-limited",
106
107
  "omni.unavailable",
107
108
  "omni.break-already-committed",
package/dist/testing.js CHANGED
@@ -902,6 +902,10 @@ async function driveOneCall(drive) {
902
902
  const declared = capabilities[name];
903
903
  return declared !== undefined && !(isRecord(declared) && declared.lockedBy !== undefined);
904
904
  };
905
+ // Media may arrive any time after the accept, before or after the task's own update says
906
+ // in-progress: the event is the provider's word that the audio should attach, never a reply to
907
+ // openMedia, so the drive looks for it from the accept rather than from the last update it read.
908
+ const acceptedAt = drive.events.length;
905
909
  // 1. Accept the offer, if it is one.
906
910
  if (latestTask().phase === "pending") {
907
911
  if (await send({ type: drive.channel === "voice" ? "answer" : "accept" }) === undefined)
@@ -929,10 +933,10 @@ async function driveOneCall(drive) {
929
933
  const started = await waitFor("task-media-started for the driven task", envelope => {
930
934
  const event = envelope.event;
931
935
  return event.type === "task-media-started" && event.taskId === taskId ? event : undefined;
932
- }, cursor);
936
+ }, acceptedAt);
933
937
  if (started === undefined)
934
938
  return found;
935
- cursor = started.at;
939
+ cursor = Math.max(cursor, started.at);
936
940
  }
937
941
  const opened = await drive.connection.openMedia?.({ taskId, localAudio: drive.localAudio });
938
942
  found.push(...validateResult(opened, "openMedia", "drive.openMedia"));
@@ -972,7 +976,10 @@ async function driveOneCall(drive) {
972
976
  }
973
977
  }
974
978
  // 6. Complete, where the agent completes; otherwise the provider does, and the drive waits for it.
975
- if (latestTask().completionMode === "agent-command" && latestTask().phase === "completing") {
979
+ // A conversation has no media to end and no completing phase to wait for: a chat or an email,
980
+ // and a voice task offering no end-call, is completed from where it stands.
981
+ const completable = latestTask().phase === "completing" || latestTask().phase === "in-progress" || latestTask().phase === "paused";
982
+ if (latestTask().completionMode === "agent-command" && completable) {
976
983
  const command = { type: "complete" };
977
984
  const dispositions = isRecord(latestTask().capabilities) ? latestTask().capabilities.dispositions : undefined;
978
985
  if (isRecord(dispositions) && dispositions.required === true && Array.isArray(dispositions.codes) && isRecord(dispositions.codes[0])) {
@@ -91,4 +91,11 @@ export interface LoginValidationContext {
91
91
  /** The level ids in force, from the manifest. The defaults when absent. */
92
92
  levels?: readonly string[];
93
93
  }
94
+ export declare function validateAuthenticationFailure(failure: unknown, path?: string): ProtocolViolation[];
95
+ /**
96
+ * What `start()` or `complete()` answered: a challenge or a rejection from `start`, a login or a
97
+ * rejection from `complete`. A rejection carries a failure the host can show, and a login is held
98
+ * to the same rules as any `authenticated` state.
99
+ */
100
+ export declare function validateAuthenticationResult(result: unknown, method: "start" | "complete", path?: string, context?: LoginValidationContext): ProtocolViolation[];
94
101
  export declare function validateAuthenticationState(state: unknown, path?: string, context?: LoginValidationContext): ProtocolViolation[];
@@ -2053,6 +2053,61 @@ function validateMonitorControl(value, path, into) {
2053
2053
  });
2054
2054
  into.require(value.includes("monitor"), "authentication.capability.team.monitorControl.monitor", path, "whisper and barge begin from a monitor: a lead who may listen in any mode may monitor");
2055
2055
  }
2056
+ /**
2057
+ * A failure an authentication reports: on a `rejected` start or completion, or on an `expired`
2058
+ * state. A provider names its own codes freely; an `omni.` code is the contract's and has to be one
2059
+ * it lists. `omni.phone-not-permitted` is never retryable: the agent's station is administrator
2060
+ * configuration, and trying again does not reconfigure it.
2061
+ */
2062
+ function validateAuthenticationFailureInto(value, path, into) {
2063
+ if (!isPlainObject(value)) {
2064
+ into.add("authentication.failure.shape", path, "a failure must be an object");
2065
+ return;
2066
+ }
2067
+ if (into.filled(value.code, "authentication.failure.code", `${path}.code`, "a failure needs a code") && value.code.startsWith("omni.")) {
2068
+ into.require(OMNI_FAILURE_CODES.includes(value.code), "failure.code.unknown", `${path}.code`, `${String(value.code)} is not a failure code this contract defines`);
2069
+ }
2070
+ into.filled(value.message, "authentication.failure.message", `${path}.message`, "a failure needs a message");
2071
+ into.require(typeof value.retryable === "boolean", "authentication.failure.retryable", `${path}.retryable`, "a failure must say whether it is retryable");
2072
+ if (value.code === "omni.phone-not-permitted") {
2073
+ into.require(value.retryable === false, "authentication.failure.phone.retryable", `${path}.retryable`, "a phone the platform does not permit for this agent is configuration: trying again does not change it, so the refusal is not retryable");
2074
+ }
2075
+ if (value.retryAfterMs !== undefined) {
2076
+ into.require(typeof value.retryAfterMs === "number" && value.retryAfterMs >= 0, "authentication.failure.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
2077
+ }
2078
+ if (value.field !== undefined)
2079
+ into.filled(value.field, "authentication.failure.field", `${path}.field`, "field names a declared credentials field when present");
2080
+ }
2081
+ export function validateAuthenticationFailure(failure, path = "failure") {
2082
+ const into = new Collector();
2083
+ validateAuthenticationFailureInto(failure, path, into);
2084
+ return into.violations;
2085
+ }
2086
+ /**
2087
+ * What `start()` or `complete()` answered: a challenge or a rejection from `start`, a login or a
2088
+ * rejection from `complete`. A rejection carries a failure the host can show, and a login is held
2089
+ * to the same rules as any `authenticated` state.
2090
+ */
2091
+ export function validateAuthenticationResult(result, method, path = "result", context = {}) {
2092
+ const into = new Collector();
2093
+ if (!isPlainObject(result)) {
2094
+ into.add("authentication.result.shape", path, `${method} must answer an object`);
2095
+ return into.violations;
2096
+ }
2097
+ if (result.status === "rejected") {
2098
+ validateAuthenticationFailureInto(result.failure, `${path}.failure`, into);
2099
+ }
2100
+ else if (method === "start" && result.status === "interaction-required") {
2101
+ into.require(isPlainObject(result.challenge), "authentication.result.challenge", `${path}.challenge`, "interaction-required carries the challenge to put to the person");
2102
+ }
2103
+ else if (method === "complete" && result.status === "authenticated") {
2104
+ into.violations.push(...validateAuthenticationState(result, path, context));
2105
+ }
2106
+ else {
2107
+ into.add("authentication.result.status", `${path}.status`, `${method} answers ${method === "start" ? "interaction-required" : "authenticated"} or rejected, not ${String(result.status)}`);
2108
+ }
2109
+ return into.violations;
2110
+ }
2056
2111
  export function validateAuthenticationState(state, path = "authentication", context = {}) {
2057
2112
  const into = new Collector();
2058
2113
  if (!isPlainObject(state)) {
@@ -2072,14 +2127,7 @@ export function validateAuthenticationState(state, path = "authentication", cont
2072
2127
  if (state.identity !== undefined)
2073
2128
  validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
2074
2129
  if (state.failure !== undefined) {
2075
- if (!isPlainObject(state.failure)) {
2076
- into.add("authentication.failure.shape", `${path}.failure`, "a failure must be an object when present");
2077
- }
2078
- else {
2079
- into.filled(state.failure.code, "authentication.failure.code", `${path}.failure.code`, "a failure needs a code");
2080
- into.filled(state.failure.message, "authentication.failure.message", `${path}.failure.message`, "a failure needs a message");
2081
- into.require(typeof state.failure.retryable === "boolean", "authentication.failure.retryable", `${path}.failure.retryable`, "a failure must say whether it is retryable");
2082
- }
2130
+ validateAuthenticationFailureInto(state.failure, `${path}.failure`, into);
2083
2131
  }
2084
2132
  }
2085
2133
  else {
package/guide.md CHANGED
@@ -1120,6 +1120,7 @@ const OMNI_FAILURE_CODES = [
1120
1120
  "omni.capability-not-enabled",
1121
1121
  "omni.task-not-found",
1122
1122
  "omni.destination-not-permitted",
1123
+ "omni.phone-not-permitted",
1123
1124
  "omni.rate-limited",
1124
1125
  "omni.unavailable",
1125
1126
  "omni.break-already-committed",
@@ -3799,6 +3800,21 @@ that is a telephone. The harness requires no `openMedia` of such an adapter and
3799
3800
  this wire never asks the agent for it: a host declares `phone` and nothing more. What a platform
3800
3801
  asks on surfaces of its own is its own decision.
3801
3802
 
3803
+ **A declared phone the platform does not permit for this agent is a login that cannot be
3804
+ established.** The manifest says what the platform can do; the agent's record, kept by an
3805
+ administrator, says what this agent is configured for, and the two can disagree with the host's
3806
+ declaration -- a softphone declared for an agent whose record says desk phone. Honouring that
3807
+ declaration on a switch that allows one registration per endpoint would evict the handset: the
3808
+ agent's phone stops ringing and their calls land in a tab, and nobody is told. So the provider
3809
+ refuses the login at authentication, with `omni.phone-not-permitted` and a message the host shows
3810
+ -- "this agent is configured for a desk phone" -- and refusing is correct, not an override of the
3811
+ host. It is a refusal, never a negotiation afterwards and never a quiet substitution, and it is
3812
+ never retryable: trying again does not reconfigure the agent, and a host validating the answer
3813
+ (`validateAuthenticationResult`) refuses a phone refusal marked otherwise
3814
+ (`authentication.failure.phone.retryable`). And **a
3815
+ provider never makes a declared phone true by changing the platform's configuration**: the record
3816
+ is the administrator's, and a login is not a request to reconfigure an agent.
3817
+
3802
3818
  ### Opening the audio
3803
3819
 
3804
3820
  `openMedia` hands Omni the remote audio for one task. Every adapter whose manifest lists
@@ -3963,6 +3979,7 @@ react rather than only display the message:
3963
3979
  | `omni.not-authenticated` | The provider session is no longer usable. The adapter has published `expired` at or before this answer — the state is what Omni surfaces reauthentication from; the code says why this action failed, and is never the only signal. |
3964
3980
  | `omni.capability-not-enabled` | The action targets a capability this task, manifest, or login did not declare — including a lead command from a login whose `capabilities` no longer carry it. |
3965
3981
  | `omni.task-not-found` | The provider-local task id is unknown, typically after the task already ended. |
3982
+ | `omni.phone-not-permitted` | The host declared a `phone` the platform does not permit for this agent -- a softphone for an agent configured for a desk phone, or the reverse. The login is refused at authentication, and the provider never reconfigures the agent to make the declaration true. See **How the agent hears the call**. |
3966
3983
  | `omni.destination-not-permitted` | The dialled number, or the `destinationId` named, is not one the provider offers this agent. |
3967
3984
  | `omni.rate-limited` | The action was throttled. Pair with `retryAfterMs`. |
3968
3985
  | `omni.unavailable` | The provider is temporarily unable to serve the action, including any command sent while `transport-status` is not `active`. |
@@ -4127,6 +4144,9 @@ partial patch.
4127
4144
  The provider's word that the task's audio should now attach. Omni calls `openMedia` on it — and on
4128
4145
  a task carried with `media: "started"`, which is how a reconnect snapshot reattaches audio an
4129
4146
  earlier event brought — and renders the call as live from that word, never from its own senses. It
4147
+ precedes `openMedia` and is never a reply to it: a provider whose media state comes from the
4148
+ platform, a station going in use the moment a call is answered, sends it then, before any host has
4149
+ opened anything, and `openMedia` has its own answer for what the host did. It
4130
4150
  names a task whose work has begun, and it alternates with `task-media-ended`: media that never
4131
4151
  started cannot end, so a live call whose provider says nothing about its audio is a provider in
4132
4152
  breach, not a state a desk fills in from its own devices.
@@ -4267,6 +4287,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
4267
4287
  | `validateHostGuarantees(guarantees)` | What a host promises: only the guarantees this contract names, each declared by presence and never `false`. The harness validates the guarantees of whatever host a test hands the adapter. |
4268
4288
  | `validateHandlingReport(report, path?, manifest?)` | What the host reports of a leg it performed, for an adapter to check before forwarding: a task, a step, when it began, a positive `seconds` where stated, and an explicit `ended` that carries the final duration. Given the manifest, a running report is refused unless it declares `runningStepReports`. |
4269
4289
  | `validateHostReport(report)` | The host's own report as published to an adapter: `online`, and where there is audio, an input that is `available` with the microphone and `flowing`, or `unavailable` with a reason and the failure that says why, and an output that is `available` or `unavailable` with its failure. The harness validates whatever host a test hands the adapter; `stillHost(report)` builds one that never changes. |
4290
+ | `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. |
4270
4291
  | `validateTaskCommand(command, task?)` | What a command needs to be issuable, against the task it names: its own shape -- a dial's `dialId`, a transfer's item, a remove naming exactly one person -- and, with the task, the capability the table above gates it on (`command.capability.<name>`, `.locked`), the phase it belongs to (`command.phase.*`), and the state that has to stand: a consulted entry, a lead requested, somebody else still on the call (`command.conference.remove.alone`). A host validates before sending and an adapter before acting. |
4271
4292
  | `validateResult(result, method)` | What a connection method answered: the status it gives, a failure where the status says so and nowhere else, the failure's shape, and that an `omni.` code is one this contract names. |
4272
4293
  | `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. Omni applies it to every state a session publishes — the republished as much as the first. |
@@ -4338,8 +4359,14 @@ complete it with a disposition where the agent completes -- and holds every step
4338
4359
  host holds a provider to. Each command is validated against the task as published
4339
4360
  (`drive.command.*`), each answer for its method, a refusal of a control the task offered is a
4340
4361
  violation (`drive.command.failed`), and an event the provider owes and never sends is one too
4341
- (`drive.timeout`, after `driveTimeoutMs`, 5000 by default). The drive stops where the task offers
4342
- no way on and says nothing about what it could not reach. It is off by default because it issues
4362
+ (`drive.timeout`, after `driveTimeoutMs`, 5000 by default). A task the agent completes is
4363
+ completed from wherever it stands once the drive has nothing left to do on it -- from `completing`
4364
+ after an `end-call`, or from `in-progress` where there is no call to end, which is every chat and
4365
+ email and a voice task offering no `endCall` -- so a conversation reaches its end as a call does.
4366
+ The drive stops where the task offers no way on and says nothing about what it could not reach. A
4367
+ softphone adapter written for a browser needs its media APIs supplied by whatever runs the
4368
+ harness: the drive opens media outside a browser, and a provider that quietly stopped carrying audio
4369
+ where `AudioContext` was missing would be lying about the one thing the channel is for. It is off by default because it issues
4343
4370
  commands against whatever platform the adapter is connected to: turn it on against a test backend.
4344
4371
 
4345
4372
  ```ts
@@ -4349,6 +4376,10 @@ assertReached(driven, ["task.onCall", "task.media", "event.task-ended"]);
4349
4376
  ```
4350
4377
  `assertReached(result, subjects)` is the paired assertion: it throws naming every subject the run
4351
4378
  never met, so a test that meant to check a roster cannot pass on a fixture that never produced one.
4379
+ It reads like a guarantee and is a claim the adopter keeps making: it catches an adapter that
4380
+ stopped reaching a subject, not a list that stopped asking, so a list can rot to nothing and stay
4381
+ green. Keep it honest with a control beside it -- one subject the run genuinely cannot reach,
4382
+ asserted to throw -- so the assertion is shown to be looking rather than agreeing.
4352
4383
 
4353
4384
  Three properties of the harness matter to adapter authors:
4354
4385
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",