@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 +43 -25
- package/dist/validation.js +10 -3
- package/guide/breaks.md +3 -1
- package/guide/queue.md +18 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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:
|
|
640
|
+
violations.push({ rule: "team.switch.rejected", path: `${at}.executeTeam`, message: `lead-features on was rejected: ${String(error)}` });
|
|
624
641
|
}
|
|
625
|
-
if (switched
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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;
|
package/dist/validation.js
CHANGED
|
@@ -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:
|
|
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
|