@xema/omni-protocol 0.1.109 → 0.1.111

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
@@ -434,6 +434,16 @@ export async function exerciseAdapter(adapter, context, options = {}) {
434
434
  message: `a task was offered with ${stream.openCount()} already open against a stated capacity of ${capacityStated}` });
435
435
  }
436
436
  }
437
+ // The agent's own queue lives within capacity: told count 0, the provider clears the ask and
438
+ // lets the lined-up call go, so an entry published after that is a promise it cannot keep.
439
+ if (capacityStated === 0 && isRecord(envelope?.event)) {
440
+ if (envelope.event.type === "next-call" && envelope.event.nextCall !== undefined) {
441
+ violations.push({ rule: "stream.nextCall.stopped", path: "event.nextCall", message: "an ask for the next call was published after the host stated count 0: the agent's capacity is elsewhere, and the ask is cleared with it" });
442
+ }
443
+ if (envelope.event.type === "lined-up" && envelope.event.linedUp !== undefined) {
444
+ violations.push({ rule: "stream.linedUp.stopped", path: "event.linedUp", message: "a call was lined up after the host stated count 0: the agent's capacity is elsewhere, and the call goes back to the queue for anyone" });
445
+ }
446
+ }
437
447
  // A re-delivered envelope is harmless and applied once; the same id carrying a different
438
448
  // payload is a reused id, which no dedupe can make harmless, and is named.
439
449
  if (typeof envelope?.id === "string") {
@@ -505,8 +515,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
505
515
  unsubscribeAuthentication = session.subscribe(onAuthenticationState);
506
516
  unsubscribe = second.subscribe(onEnvelope);
507
517
  clientLive = true;
518
+ // The second client has been told nothing: the switch is per connection, and the reader
519
+ // holds its connect snapshot to no team until it is sent again, after its capacity.
520
+ leadFeaturesOn = false;
508
521
  const restated = await second.setCapacity({ count: capacityStated ?? 1 });
509
522
  violations.push(...validateResult(restated, "setCapacity", "drive.reload.setCapacity"));
523
+ await switchOnLeadFeatures(second, "drive.reload");
510
524
  },
511
525
  };
512
526
  // Every snapshot the run reads, the connect snapshot and a reloaded client's, is read the same
@@ -609,40 +623,44 @@ export async function exerciseAdapter(adapter, context, options = {}) {
609
623
  }
610
624
  // The provider assumes nothing of the team feature: the application says on every connect,
611
625
  // and once it has said on, the whole team is owed, once. Before that nothing of the team
612
- // reaches the lead, which the reader held the connect snapshot to.
613
- if (current().capabilities.lead === true && typeof live.executeTeam === "function") {
626
+ // reaches the lead, which the reader held the connect snapshot to. A reloaded client is a
627
+ // connect too: it is told again, after its capacity, and held the same way.
628
+ const switchOnLeadFeatures = async (on, at) => {
629
+ if (current().capabilities.lead !== true || typeof on.executeTeam !== "function")
630
+ return;
614
631
  const from = events.length;
615
632
  let switched;
616
633
  // On from the moment the application says so: the provider may answer with the team at once.
617
634
  leadFeaturesOn = true;
618
635
  try {
619
- switched = await live.executeTeam({ command: { type: "lead-features", enabled: true } });
636
+ switched = await on.executeTeam({ command: { type: "lead-features", enabled: true } });
620
637
  }
621
638
  catch (error) {
622
639
  leadFeaturesOn = false;
623
- violations.push({ rule: "team.switch.rejected", path: "connection.executeTeam", message: `lead-features on was rejected: ${String(error)}` });
640
+ violations.push({ rule: "team.switch.rejected", path: `${at}.executeTeam`, message: `lead-features on was rejected: ${String(error)}` });
624
641
  }
625
- if (switched !== undefined) {
626
- violations.push(...validateResult(switched, "executeTeam", "connection.executeTeam"));
627
- ruleEvaluated("team.required");
628
- const ms = options.driveTimeoutMs ?? 5000;
629
- const isBaseline = (envelope) => isRecord(envelope?.event) && envelope.event.type === "team-updated";
630
- const baseline = events.slice(from).some(isBaseline) || await new Promise(resolve => {
631
- const timer = setTimeout(() => { waiters.delete(waiter); resolve(false); }, ms);
632
- const waiter = (envelope) => {
633
- if (!isBaseline(envelope))
634
- return;
635
- clearTimeout(timer);
636
- waiters.delete(waiter);
637
- resolve(true);
638
- };
639
- waiters.add(waiter);
640
- });
641
- if (!baseline)
642
- violations.push({ rule: "team.required", path: "event.team",
643
- message: `the application switched the team feature on and no team-updated followed within ${ms}ms: the whole team is owed once it is on, members: [] when nobody is in it` });
644
- }
645
- }
642
+ if (switched === undefined)
643
+ return;
644
+ violations.push(...validateResult(switched, "executeTeam", `${at}.executeTeam`));
645
+ ruleEvaluated("team.required");
646
+ const ms = options.driveTimeoutMs ?? 5000;
647
+ const isBaseline = (envelope) => isRecord(envelope?.event) && envelope.event.type === "team-updated";
648
+ const baseline = events.slice(from).some(isBaseline) || await new Promise(resolve => {
649
+ const timer = setTimeout(() => { waiters.delete(waiter); resolve(false); }, ms);
650
+ const waiter = (envelope) => {
651
+ if (!isBaseline(envelope))
652
+ return;
653
+ clearTimeout(timer);
654
+ waiters.delete(waiter);
655
+ resolve(true);
656
+ };
657
+ waiters.add(waiter);
658
+ });
659
+ if (!baseline)
660
+ violations.push({ rule: "team.required", path: "event.team",
661
+ message: `the application switched the team feature on and no team-updated followed within ${ms}ms: the whole team is owed once it is on, members: [] when nobody is in it` });
662
+ };
663
+ await switchOnLeadFeatures(live, "connection");
646
664
  if (options.drive) {
647
665
  const localAudio = isRecord(first) && isRecord(first.audio) && isRecord(first.audio.input) && first.audio.input.status === "available"
648
666
  ? first.audio.input.localAudio : undefined;
@@ -1795,14 +1795,21 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1795
1795
  validateShiftInto(snapshot.shift, `${path}.shift`, into, "shift");
1796
1796
  // The phone as the platform sees it: owed by a provider that declares it can, and by nobody else.
1797
1797
  const seesPhone = isPlainObject(manifest) && manifest.phoneStatus === true;
1798
- // The agent's own queue: an ask needs an assignment at work to be next after, and one call at most waits.
1798
+ // The agent's own queue: the ask and the lined-up call need an assignment at work to be next
1799
+ // after, and neither stands beside a break the agent asked for or was put on -- the break wins.
1800
+ const atWork = Array.isArray(snapshot.tasks) && snapshot.tasks.some((task) => isPlainObject(task) && (task.phase === "in-progress" || task.phase === "paused" || task.phase === "completing"));
1801
+ const breakStatus = isPlainObject(snapshot.break) ? snapshot.break.status : undefined;
1802
+ const breakStands = typeof breakStatus === "string" && breakStatus !== "not-requested";
1799
1803
  if (snapshot.nextCall !== undefined) {
1800
1804
  validateNextCallInto(snapshot.nextCall, `${path}.nextCall`, into);
1801
- const atWork = Array.isArray(snapshot.tasks) && snapshot.tasks.some((task) => isPlainObject(task) && (task.phase === "in-progress" || task.phase === "paused" || task.phase === "completing"));
1802
1805
  into.require(atWork, "snapshot.nextCall.idle", `${path}.nextCall`, "an ask for the next call stands only while an assignment is at work to be next after: the provider clears it when that assignment ends");
1806
+ into.require(!breakStands, "snapshot.nextCall.break", `${path}.nextCall`, `an ask for the next call does not stand beside a break that is ${String(breakStatus)}: the break wins, and the provider clears the ask`);
1803
1807
  }
1804
- if (snapshot.linedUp !== undefined)
1808
+ if (snapshot.linedUp !== undefined) {
1805
1809
  validateLinedUpInto(snapshot.linedUp, `${path}.linedUp`, into, levels);
1810
+ into.require(atWork, "snapshot.linedUp.idle", `${path}.linedUp`, "a call is lined up for an agent with an assignment at work; with nothing at work it is an offer, and travels as task-offered");
1811
+ into.require(!breakStands, "snapshot.linedUp.break", `${path}.linedUp`, `a lined-up call does not stand beside a break that is ${String(breakStatus)}: the break wins, and the call goes back to the queue for anyone`);
1812
+ }
1806
1813
  if (seesPhone && snapshot.phone === undefined) {
1807
1814
  into.add("snapshot.phone.required", `${path}.phone`, "the manifest declares phoneStatus, so every snapshot carries the phone as the platform sees it");
1808
1815
  }
package/guide/breaks.md CHANGED
@@ -253,4 +253,6 @@ it: the ceiling binds assignment, not the agent's own hand.
253
253
  **Capacity is not the agent's readiness.** It is the desk's division of one person across
254
254
  providers, a ceiling on assignments at once, and it says nothing about the moment. An agent who
255
255
  is about to finish a call and wants the next one lined up has a different thing to say, and says
256
- it as **The agent's own queue** in `guide/queue.md` sets out; the ceiling stands unchanged while they do.
256
+ it as **The agent's own queue** in `guide/queue.md` sets out; the ceiling stands unchanged while they do,
257
+ and the queue lives within it: `count: 0` clears the ask and lets a lined-up call go, and a break
258
+ in flight or in effect does the same, since the break wins.
package/guide/queue.md CHANGED
@@ -44,6 +44,24 @@ its own when that assignment ends, when a call is lined up to meet it, and when
44
44
  withdraws it with `cancelNextCall()`. The desk never keeps it past any of those. `requested` says
45
45
  the provider holds the ask, not that a call will come: a queue may be empty.
46
46
 
47
+ **The queue lives within capacity, and never beside a break.** The ask and the lined-up call are
48
+ promises to assign, and a promise the provider can no longer keep goes:
49
+
50
+ - **Told `count: 0`**, host-stopped, the provider assigns nothing, so it clears the ask and lets the
51
+ lined-up call go back to the queue for anyone; an entry published after that is refused
52
+ (`stream.nextCall.stopped`, `stream.linedUp.stopped`). The queue is per provider, as the ask
53
+ was made on that provider's call, and the desk's division of capacity is what it was.
54
+ - **A break wins.** An agent who has asked for a break, been granted one starting after this
55
+ task, or been put on one has said the opposite of "line up my next call", and the two never
56
+ stand together: while a break is in flight or in effect -- any status but `not-requested` --
57
+ `requestNextCall()` is refused with `omni.unavailable`, and a break arriving after a
58
+ call was lined up drops the call, the caller going back to the queue for anyone
59
+ (`snapshot.nextCall.break`, `snapshot.linedUp.break`). The desk shows Next call or the break
60
+ controls, never both.
61
+ - **A lined-up call needs an agent at work** as the ask does: a call lined up for an agent with
62
+ nothing at work is an offer that has not been made, and travels as `task-offered`
63
+ (`snapshot.linedUp.idle`). The repeat caller for an idle agent is an ordinary offer.
64
+
47
65
  **The lined-up call is not a task, and rides no task event.** Until the offer there is no
48
66
  assignment and nothing to accept, so it has no phase, no capabilities and no history, and it
49
67
  rings nothing: the caller is still in the platform's queue, the phone has no channel for it, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.109",
3
+ "version": "0.1.111",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",