@xema/omni-protocol 0.1.57 → 0.1.59

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/testing.js CHANGED
@@ -784,6 +784,17 @@ export class TaskStream {
784
784
  const known = id === undefined ? undefined : this.tasks.get(id);
785
785
  switch (event.type) {
786
786
  case "snapshot":
787
+ // A snapshot replaces what is known, and still may not say a task lost terms it had read.
788
+ if (isRecord(event.snapshot) && Array.isArray(event.snapshot.tasks)) {
789
+ event.snapshot.tasks.forEach((task, index) => {
790
+ if (!isRecord(task) || typeof task.id !== "string")
791
+ return;
792
+ const was = this.tasks.get(task.id);
793
+ if (was !== undefined && (was.source === "queue" || was.source === "ungoverned") && task.capabilitySource === "undetermined") {
794
+ refuse("stream.snapshot.capabilitySource", `${at}.snapshot.tasks[${index}].capabilitySource`, `${task.id} was published under ${was.source} terms and the snapshot says undetermined: terms once read stay read`);
795
+ }
796
+ });
797
+ }
787
798
  this.seed(event.snapshot);
788
799
  break;
789
800
  case "task-offered":
@@ -1042,7 +1053,21 @@ async function driveOneCall(drive) {
1042
1053
  }, cursor);
1043
1054
  if (mediaEnded !== undefined)
1044
1055
  cursor = mediaEnded.at;
1045
- await updated(t => t.phase === "completing", "the task completing after its media ended");
1056
+ if (await updated(t => t.phase === "completing", "the task completing after its media ended") !== undefined && offers("hold")) {
1057
+ // The other direction of step 4: the same control, still declared, on a call that is over.
1058
+ // A host holds it back (command.phase.handling), and an adapter that receives it anyway
1059
+ // must refuse it -- so the drive sends it past the validator and expects failed.
1060
+ let answer;
1061
+ try {
1062
+ answer = await drive.connection.execute({ taskId, command: { type: "hold" } });
1063
+ }
1064
+ catch (error) {
1065
+ refuse("drive.command.rejected", "drive.command.hold", `execute rejected rather than answered: ${String(error)}`);
1066
+ }
1067
+ if (isRecord(answer) && answer.status !== "failed") {
1068
+ refuse("drive.command.handling", "drive.command.hold", `the provider applied hold on a completing task: a control on the contact belongs to in-progress or paused, and the adapter is the second gate`);
1069
+ }
1070
+ }
1046
1071
  }
1047
1072
  }
1048
1073
  if (typeof session?.close === "function") {
@@ -1831,6 +1831,10 @@ export function validateTaskCommand(command, task, path = "command") {
1831
1831
  return true;
1832
1832
  };
1833
1833
  const inPhase = (rule, ...phases) => into.require(phases.includes(String(task.phase)), rule, path, `${String(type)} belongs to ${phases.join(" or ")}, and the task is ${String(task.phase)}`);
1834
+ // A control on the contact acts on a contact being handled. Before in-progress there is nothing
1835
+ // to act on yet, and in completing the handling has ended: a call with nobody on it, a
1836
+ // conversation closed. What a capability offers, the phase decides whether there is anything to use it on.
1837
+ const handling = () => inPhase("command.phase.handling", "in-progress", "paused");
1834
1838
  const onCall = Array.isArray(task.onCall) ? task.onCall.filter(isPlainObject) : [];
1835
1839
  switch (type) {
1836
1840
  case "answer":
@@ -1846,23 +1850,28 @@ export function validateTaskCommand(command, task, path = "command") {
1846
1850
  break;
1847
1851
  case "mute":
1848
1852
  offered("mute");
1853
+ handling();
1849
1854
  break;
1850
1855
  case "hold":
1851
1856
  case "resume":
1852
1857
  case "pause":
1853
1858
  offered("hold");
1859
+ handling();
1854
1860
  break;
1855
1861
  case "end-call":
1856
1862
  offered("endCall");
1863
+ handling();
1857
1864
  break;
1858
1865
  case "recording":
1859
1866
  offered("recording");
1867
+ handling();
1860
1868
  break;
1861
1869
  case "connect-back":
1862
1870
  offered("connectBack");
1863
1871
  inPhase("command.phase.completing", "completing");
1864
1872
  break;
1865
1873
  case "transfer":
1874
+ handling();
1866
1875
  if (command.action === "cold")
1867
1876
  offered("coldTransfer");
1868
1877
  else if (command.action === "warm")
@@ -1872,6 +1881,7 @@ export function validateTaskCommand(command, task, path = "command") {
1872
1881
  }
1873
1882
  break;
1874
1883
  case "lead-assist":
1884
+ handling();
1875
1885
  if (command.action === "request" || command.action === "cancel") {
1876
1886
  offered("leadAssist");
1877
1887
  if (command.action === "cancel") {
@@ -1883,6 +1893,7 @@ export function validateTaskCommand(command, task, path = "command") {
1883
1893
  }
1884
1894
  break;
1885
1895
  case "conference":
1896
+ handling();
1886
1897
  if (offered("conference") && command.action === "remove") {
1887
1898
  const others = onCall.filter(entry => entry.role !== "agent");
1888
1899
  if (command.party === true) {
package/guide.md CHANGED
@@ -2728,7 +2728,8 @@ the terms arrive, the task is republished under `queue` with the set as it now s
2728
2728
  republish as any other permission that changed while the task was open. The move goes one way.
2729
2729
  Terms once read stay read: a re-read that fails mid-task is not a new fact about the task, so the
2730
2730
  last statement stands and the failure is a `diagnostic`, and a task that was published under
2731
- `queue` or `ungoverned` never returns to `undetermined` (`stream.taskUpdated.capabilitySource`).
2731
+ `queue` or `ungoverned` never returns to `undetermined`, on an update (`stream.taskUpdated.capabilitySource`)
2732
+ or on a resync snapshot (`stream.snapshot.capabilitySource`).
2732
2733
 
2733
2734
  What the agent is told differs by source, and only one source tells them anything. Under `queue`
2734
2735
  and `ungoverned` the agent sees controls and nothing about where they came from: both are facts,
@@ -2736,8 +2737,16 @@ and an agent working a call has no use for the name of the rule behind its butto
2736
2737
  `undetermined` the agent is told, beside the controls, that these are what the provider will honour
2737
2738
  until the queue's terms arrive, and that the controls may change when they do -- a statement about
2738
2739
  the buttons in front of them now, not about the provider, because that is what changes when the
2739
- republish lands. The two words are close in English and far apart on the desk: `ungoverned` is
2740
- silence, `undetermined` is a notice. `exerciseAdapter` treats a
2740
+ republish lands. It stays for as long as the set is provisional, beside the controls it qualifies;
2741
+ a message that shows and clears has said nothing about the buttons still on the screen. The two
2742
+ words are close in English and far apart on the desk: `ungoverned` is silence, `undetermined` is a
2743
+ standing notice.
2744
+
2745
+ One consequence for whoever builds the host: because a conformance run fails on `undetermined`, a
2746
+ conformant adapter never shows a host `undetermined` under test, and a clean `exerciseAdapter`
2747
+ result says nothing about how the host renders it. That rendering is tested against a fixture --
2748
+ a task published under `undetermined` shows the notice, the same task under `queue` shows none --
2749
+ and never against an adapter, even in principle. `exerciseAdapter` treats a
2741
2750
  task published under `undetermined` as a violation (`capabilitySource.undetermined`), as it treats
2742
2751
  a diagnostic: a conformance run against a platform that cannot say what it permits fails loudly
2743
2752
  rather than passing with a note.
@@ -2753,6 +2762,11 @@ const taskCapabilities = {
2753
2762
  } satisfies Pick<Task<"voice">, "channel" | "capabilities" | "browsers">;
2754
2763
  ```
2755
2764
 
2765
+ A capability says the control may be offered; the task's phase says whether there is anything to use
2766
+ it on. Every control below that acts on the call or the conversation -- everything but `decline`,
2767
+ `connectBack` and `dispositions` -- is offered only while the task is `in-progress` or `paused`.
2768
+ See **Which commands need a capability**.
2769
+
2756
2770
  ### Voice capabilities
2757
2771
 
2758
2772
  | Capability | Omni UI | Contract |
@@ -4000,6 +4014,17 @@ declared:
4000
4014
  | `lead-assist` with `action: "take-over"` or `"leave"` | The lead's own task, on a call they joined -- `Task.assisting` present. An agent's task never has it, and a provider that receives either without it answers `failed`. |
4001
4015
  | Everything else | Its own named capability. |
4002
4016
 
4017
+ **A control on the contact belongs to the handling phases**, `in-progress` and `paused`: `mute`,
4018
+ `hold`, `resume` and `pause`, `end-call`, `recording`, every `transfer` and `conference` action, and
4019
+ every `lead-assist` action. Each acts on the call or the conversation, and only while there is one.
4020
+ Before `in-progress` nothing has been placed or opened; in `completing` the handling has ended -- a
4021
+ call with nobody on it, a conversation closed -- and a wrap-up that still shows Transfer shows it
4022
+ for nothing. The capability stays declared, because it is a property of the task and the task is
4023
+ still open; the phase says there is nothing to use it on. Omni shows none of these controls outside
4024
+ the two phases, and `validateTaskCommand` refuses each of them there (`command.phase.handling`).
4025
+ The commands with a phase of their own -- `answer`, `accept`, `decline` and `reject` in `pending`,
4026
+ `call` in `preview`, `connect-back` in `completing`, `complete` in any -- are not among them.
4027
+
4003
4028
  `validateTaskCommand(command, task)` holds a command to this table at runtime, both ways: the
4004
4029
  capability it needs, the phase it belongs to, and the state that has to stand. The task it wants
4005
4030
  is the one the provider published, not a host's own mapping of it: a host that keeps only its
@@ -4368,7 +4393,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
4368
4393
  | `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`. |
4369
4394
  | `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. |
4370
4395
  | `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. |
4371
- | `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. |
4396
+ | `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.*`, `command.phase.handling` for every control on the call or the conversation), 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. |
4372
4397
  | `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. |
4373
4398
  | `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. |
4374
4399
 
@@ -4439,7 +4464,10 @@ complete it with a disposition where the agent completes -- and holds every step
4439
4464
  host holds a provider to. Each command is validated against the task as published
4440
4465
  (`drive.command.*`), each answer for its method, a refusal of a control the task offered is a
4441
4466
  violation (`drive.command.failed`), and an event the provider owes and never sends is one too
4442
- (`drive.timeout`, after `driveTimeoutMs`, 5000 by default). A task the agent completes is
4467
+ (`drive.timeout`, after `driveTimeoutMs`, 5000 by default). Once the call has ended and the task is
4468
+ `completing`, the drive sends `hold` once more, past the validator that would hold it back, and
4469
+ expects `failed`: the adapter is the second gate on a control on the contact, and one that applies
4470
+ it on a call that is over is named (`drive.command.handling`). A task the agent completes is
4443
4471
  completed from wherever it stands once the drive has nothing left to do on it -- from `completing`
4444
4472
  after an `end-call`, or from `in-progress` where there is no call to end, which is every chat and
4445
4473
  email and a voice task offering no `endCall` -- so a conversation reaches its end as a call does.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.57",
3
+ "version": "0.1.59",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",