@xema/omni-protocol 0.1.51 → 0.1.53
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.d.ts +10 -0
- package/dist/testing.js +186 -1
- package/dist/validation.js +6 -0
- package/guide.md +40 -0
- package/package.json +1 -1
package/dist/testing.d.ts
CHANGED
|
@@ -33,6 +33,16 @@ export interface AdapterContractResult {
|
|
|
33
33
|
export interface ExerciseAdapterOptions {
|
|
34
34
|
/** Return violations in the result instead of throwing. Defaults to `false`. */
|
|
35
35
|
collectOnly?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Drive one ordinary lifecycle on the first task the provider offers -- accept it, open its
|
|
38
|
+
* media on a softphone, hold and resume where offered, end the call where offered, complete it
|
|
39
|
+
* where the agent completes -- so the rules about a live call are reached rather than listed
|
|
40
|
+
* under `notExercised`. Off by default, since it issues commands against whatever platform the
|
|
41
|
+
* adapter is connected to: turn it on against a test backend.
|
|
42
|
+
*/
|
|
43
|
+
drive?: boolean;
|
|
44
|
+
/** How long the drive waits for each thing the provider owes it. Defaults to 5000. */
|
|
45
|
+
driveTimeoutMs?: number;
|
|
36
46
|
}
|
|
37
47
|
/**
|
|
38
48
|
* Adapter conformance exercise: validates the manifest, opens an authenticated session,
|
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
|
|
2
|
-
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateManifest, validateResult, validateSnapshot, validateTimeZone, validatePhone, isTimeZone, sameTimeZone, } from "./validation.js";
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateManifest, validateResult, validateSnapshot, 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
|
|
@@ -290,6 +290,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
290
290
|
if (softphone)
|
|
291
291
|
requireMethod(live, "openMedia", "the login is on a softphone");
|
|
292
292
|
const eventIds = new Set();
|
|
293
|
+
// The drive waits on events: each waiter is offered every envelope as it lands.
|
|
294
|
+
const waiters = new Set();
|
|
293
295
|
unsubscribe = connection.subscribe(envelope => {
|
|
294
296
|
observeEvent(envelope, seen);
|
|
295
297
|
violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader()));
|
|
@@ -312,6 +314,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
312
314
|
eventIds.add(envelope.id);
|
|
313
315
|
}
|
|
314
316
|
events.push(envelope);
|
|
317
|
+
for (const waiter of waiters)
|
|
318
|
+
waiter(envelope);
|
|
315
319
|
});
|
|
316
320
|
const snapshot = await connection.snapshot();
|
|
317
321
|
observeSnapshot(snapshot, seen);
|
|
@@ -359,6 +363,14 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
359
363
|
message: `the provider would not accept a capacity: ${capacity.failure.code}`,
|
|
360
364
|
});
|
|
361
365
|
}
|
|
366
|
+
if (options.drive) {
|
|
367
|
+
const localAudio = isRecord(first) && isRecord(first.audio) && isRecord(first.audio.input) && first.audio.input.status === "available"
|
|
368
|
+
? first.audio.input.localAudio : undefined;
|
|
369
|
+
violations.push(...await driveOneCall({
|
|
370
|
+
connection: live, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
|
|
371
|
+
timeoutMs: options.driveTimeoutMs ?? 5000,
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
362
374
|
}
|
|
363
375
|
finally {
|
|
364
376
|
let clean = true;
|
|
@@ -801,6 +813,179 @@ export class TaskStream {
|
|
|
801
813
|
return found;
|
|
802
814
|
}
|
|
803
815
|
}
|
|
816
|
+
/**
|
|
817
|
+
* One ordinary lifecycle on the first task offered, each step held to the rules a host holds a
|
|
818
|
+
* provider to: the command validated against the task as published, the result validated for the
|
|
819
|
+
* method, and the event the provider owes in return awaited -- a step that never arrives is a
|
|
820
|
+
* violation naming what was owed. The drive stops where the task offers no way on (no `endCall`,
|
|
821
|
+
* a provider that completes for itself) and says nothing about what it could not reach.
|
|
822
|
+
*/
|
|
823
|
+
async function driveOneCall(drive) {
|
|
824
|
+
const found = [];
|
|
825
|
+
const refuse = (rule, path, message) => found.push({ rule, path, message });
|
|
826
|
+
const isTask = (value) => isRecord(value) && typeof value.id === "string";
|
|
827
|
+
// Wait for an envelope that satisfies `matches`, looking first at what already arrived.
|
|
828
|
+
const waitFor = (what, matches, from) => new Promise(resolve => {
|
|
829
|
+
for (let index = from; index < drive.events.length; index += 1) {
|
|
830
|
+
const hit = matches(drive.events[index]);
|
|
831
|
+
if (hit !== undefined) {
|
|
832
|
+
resolve({ found: hit, at: index + 1 });
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
const timer = setTimeout(() => {
|
|
837
|
+
drive.waiters.delete(waiter);
|
|
838
|
+
refuse("drive.timeout", "drive", `the provider owed ${what} within ${drive.timeoutMs}ms and it never arrived`);
|
|
839
|
+
resolve(undefined);
|
|
840
|
+
}, drive.timeoutMs);
|
|
841
|
+
const waiter = (envelope) => {
|
|
842
|
+
const hit = matches(envelope);
|
|
843
|
+
if (hit === undefined)
|
|
844
|
+
return;
|
|
845
|
+
clearTimeout(timer);
|
|
846
|
+
drive.waiters.delete(waiter);
|
|
847
|
+
resolve({ found: hit, at: drive.events.length });
|
|
848
|
+
};
|
|
849
|
+
drive.waiters.add(waiter);
|
|
850
|
+
});
|
|
851
|
+
// The task, as first seen: on the snapshot, or on the first offer.
|
|
852
|
+
let cursor = 0;
|
|
853
|
+
let task = isRecord(drive.snapshot) && Array.isArray(drive.snapshot.tasks) ? drive.snapshot.tasks.find(isTask) : undefined;
|
|
854
|
+
if (task === undefined) {
|
|
855
|
+
const offered = await waitFor("a task-offered", envelope => {
|
|
856
|
+
const event = envelope.event;
|
|
857
|
+
return event.type === "task-offered" && isTask(event.task) ? event.task : undefined;
|
|
858
|
+
}, 0);
|
|
859
|
+
if (offered === undefined)
|
|
860
|
+
return found;
|
|
861
|
+
task = offered.found;
|
|
862
|
+
cursor = offered.at;
|
|
863
|
+
}
|
|
864
|
+
const taskId = task.id;
|
|
865
|
+
const latestTask = () => task;
|
|
866
|
+
const updated = (until, what) => waitFor(what, envelope => {
|
|
867
|
+
const event = envelope.event;
|
|
868
|
+
if ((event.type === "task-updated" || event.type === "task-offered") && isTask(event.task) && event.task.id === taskId) {
|
|
869
|
+
task = event.task;
|
|
870
|
+
return until(event.task) ? event.task : undefined;
|
|
871
|
+
}
|
|
872
|
+
return undefined;
|
|
873
|
+
}, cursor).then(hit => { if (hit)
|
|
874
|
+
cursor = hit.at; return hit; });
|
|
875
|
+
const ended = () => waitFor("a task-ended for the driven task", envelope => {
|
|
876
|
+
const event = envelope.event;
|
|
877
|
+
return event.type === "task-ended" && event.taskId === taskId ? event : undefined;
|
|
878
|
+
}, cursor);
|
|
879
|
+
// Every command the drive sends is validated against the task as published, and its answer for its method.
|
|
880
|
+
const send = async (command, dialId) => {
|
|
881
|
+
const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`);
|
|
882
|
+
found.push(...own);
|
|
883
|
+
if (own.length > 0)
|
|
884
|
+
return undefined;
|
|
885
|
+
let result;
|
|
886
|
+
try {
|
|
887
|
+
result = await drive.connection.execute({ taskId, command });
|
|
888
|
+
}
|
|
889
|
+
catch (error) {
|
|
890
|
+
refuse("drive.command.rejected", `drive.command.${String(command.type)}`, `execute rejected rather than answered: ${String(error)}`);
|
|
891
|
+
return undefined;
|
|
892
|
+
}
|
|
893
|
+
found.push(...validateResult(result, "execute", `drive.command.${String(command.type)}.result`, dialId));
|
|
894
|
+
if (isRecord(result) && result.status === "failed") {
|
|
895
|
+
refuse("drive.command.failed", `drive.command.${String(command.type)}`, `the provider refused ${String(command.type)} on a task that offered it: ${String(isRecord(result.failure) ? result.failure.code : result.failure)}`);
|
|
896
|
+
return undefined;
|
|
897
|
+
}
|
|
898
|
+
return isRecord(result) ? result : undefined;
|
|
899
|
+
};
|
|
900
|
+
const offers = (name) => {
|
|
901
|
+
const capabilities = isRecord(latestTask().capabilities) ? latestTask().capabilities : {};
|
|
902
|
+
const declared = capabilities[name];
|
|
903
|
+
return declared !== undefined && !(isRecord(declared) && declared.lockedBy !== undefined);
|
|
904
|
+
};
|
|
905
|
+
// 1. Accept the offer, if it is one.
|
|
906
|
+
if (latestTask().phase === "pending") {
|
|
907
|
+
if (await send({ type: drive.channel === "voice" ? "answer" : "accept" }) === undefined)
|
|
908
|
+
return found;
|
|
909
|
+
if (await updated(t => t.phase !== "pending", "the task leaving pending after it was accepted") === undefined)
|
|
910
|
+
return found;
|
|
911
|
+
}
|
|
912
|
+
// 2. A preview: press Call, which is a dial.
|
|
913
|
+
if (latestTask().phase === "preview") {
|
|
914
|
+
const dialId = `drive-${taskId}`;
|
|
915
|
+
drive.stream.dialled(dialId);
|
|
916
|
+
if (await send({ type: "call", dialId }, dialId) === undefined)
|
|
917
|
+
return found;
|
|
918
|
+
if (await updated(t => t.phase === "in-progress" || t.phase === "completing", "the task leaving preview after Call") === undefined)
|
|
919
|
+
return found;
|
|
920
|
+
}
|
|
921
|
+
if (latestTask().phase === "confirmed") {
|
|
922
|
+
if (await updated(t => t.phase !== "confirmed", "the task leaving confirmed") === undefined)
|
|
923
|
+
return found;
|
|
924
|
+
}
|
|
925
|
+
// 3. On a softphone, the audio arrives and the host opens it.
|
|
926
|
+
let session;
|
|
927
|
+
if (drive.softphone && latestTask().phase === "in-progress") {
|
|
928
|
+
if (latestTask().media !== "started") {
|
|
929
|
+
const started = await waitFor("task-media-started for the driven task", envelope => {
|
|
930
|
+
const event = envelope.event;
|
|
931
|
+
return event.type === "task-media-started" && event.taskId === taskId ? event : undefined;
|
|
932
|
+
}, cursor);
|
|
933
|
+
if (started === undefined)
|
|
934
|
+
return found;
|
|
935
|
+
cursor = started.at;
|
|
936
|
+
}
|
|
937
|
+
const opened = await drive.connection.openMedia?.({ taskId, localAudio: drive.localAudio });
|
|
938
|
+
found.push(...validateResult(opened, "openMedia", "drive.openMedia"));
|
|
939
|
+
if (isRecord(opened) && opened.status === "opened")
|
|
940
|
+
session = opened.session;
|
|
941
|
+
else if (isRecord(opened))
|
|
942
|
+
refuse("drive.openMedia.unavailable", "drive.openMedia", "a softphone login's adapter could not open the call's audio");
|
|
943
|
+
}
|
|
944
|
+
// 4. Hold and resume, where offered.
|
|
945
|
+
if (latestTask().phase === "in-progress" && offers("hold")) {
|
|
946
|
+
if (await send({ type: drive.channel === "chat" ? "pause" : "hold" }) !== undefined) {
|
|
947
|
+
if (await updated(t => t.phase === "paused", "the task pausing after hold") !== undefined) {
|
|
948
|
+
if (await send({ type: "resume" }) !== undefined) {
|
|
949
|
+
await updated(t => t.phase === "in-progress", "the task resuming after resume");
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
// 5. End the call, where the agent may.
|
|
955
|
+
if (drive.channel === "voice" && latestTask().phase === "in-progress" && offers("endCall")) {
|
|
956
|
+
if (await send({ type: "end-call" }) !== undefined) {
|
|
957
|
+
const mediaEnded = await waitFor("task-media-ended after end-call", envelope => {
|
|
958
|
+
const event = envelope.event;
|
|
959
|
+
return event.type === "task-media-ended" && event.taskId === taskId ? event : undefined;
|
|
960
|
+
}, cursor);
|
|
961
|
+
if (mediaEnded !== undefined)
|
|
962
|
+
cursor = mediaEnded.at;
|
|
963
|
+
await updated(t => t.phase === "completing", "the task completing after its media ended");
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
if (typeof session?.close === "function") {
|
|
967
|
+
try {
|
|
968
|
+
session.close();
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
refuse("drive.openMedia.close", "drive.openMedia", "the media session threw on close");
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// 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") {
|
|
976
|
+
const command = { type: "complete" };
|
|
977
|
+
const dispositions = isRecord(latestTask().capabilities) ? latestTask().capabilities.dispositions : undefined;
|
|
978
|
+
if (isRecord(dispositions) && dispositions.required === true && Array.isArray(dispositions.codes) && isRecord(dispositions.codes[0])) {
|
|
979
|
+
command.disposition = dispositions.codes[0].id;
|
|
980
|
+
}
|
|
981
|
+
if (await send(command) !== undefined)
|
|
982
|
+
await ended();
|
|
983
|
+
}
|
|
984
|
+
else if (latestTask().phase === "completing") {
|
|
985
|
+
await ended();
|
|
986
|
+
}
|
|
987
|
+
return found;
|
|
988
|
+
}
|
|
804
989
|
// A request goes not-requested -> awaiting-decision | granted; a commit goes granted ->
|
|
805
990
|
// starting-after-task | in-effect; work ending goes starting-after-task -> in-effect; a denial,
|
|
806
991
|
// a cancel, an end or a release goes back to not-requested; a placed break arrives in-effect
|
package/dist/validation.js
CHANGED
|
@@ -956,6 +956,12 @@ function validateTaskInto(task, context, path, into) {
|
|
|
956
956
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into);
|
|
957
957
|
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into);
|
|
958
958
|
validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
|
|
959
|
+
// The room is who is on the call now, and a task outlives its call by the whole of wrap-up: a
|
|
960
|
+
// task whose call has ended -- completing, or media ended -- carries nobody, or the last thing the
|
|
961
|
+
// wire said about the call stays true for ever. Empty and absent both say nobody.
|
|
962
|
+
if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
|
|
963
|
+
into.add("task.onCall.ended", `${path}.onCall`, `the call has ended (${task.phase === "completing" ? "the task is completing" : "its media ended"}) and onCall still names people on it: the room is who is on the call now, and now nobody is`);
|
|
964
|
+
}
|
|
959
965
|
validateTaskMedia(task.media, context.channel, `${path}.media`, into);
|
|
960
966
|
validateLeadAssist(task.leadAssist, context.channel, `${path}.leadAssist`, into);
|
|
961
967
|
validateAssisting(task.assisting, context.channel, `${path}.assisting`, into);
|
package/guide.md
CHANGED
|
@@ -2994,6 +2994,26 @@ the platform added itself carries none. `held: true` is presence as claim. A sna
|
|
|
2994
2994
|
after a transfer reads the room from here rather than inferring it from a sequence of outcomes it
|
|
2995
2995
|
never saw.
|
|
2996
2996
|
|
|
2997
|
+
**`onCall` is who is on the call now, and a task outlives its call.** Wrap-up is not an ending:
|
|
2998
|
+
the task is alive, the agent is working, and nothing arrives to say the call ended until they
|
|
2999
|
+
dispose. So the room must not be the last word about a call that has ended. Once the call is over
|
|
3000
|
+
-- the task `completing`, or its `media` ended -- `onCall` is empty, or absent only where the
|
|
3001
|
+
provider never publishes the room at all, and a task carrying people on a call that has ended is
|
|
3002
|
+
refused (`task.onCall.ended`). Empty says nobody is on the call; absent says the provider does not
|
|
3003
|
+
say who is, which is a different claim, so a provider that publishes the room publishes one last
|
|
3004
|
+
change: the empty room.
|
|
3005
|
+
|
|
3006
|
+
**A field that describes the present is cleared by the transition that ends it.** `onCall`,
|
|
3007
|
+
`previewEndsAt` and `atDeadline` are three instances of one shape, and there will be more: each
|
|
3008
|
+
describes a state that is true now, and each is carried past the moment it stops being true by the
|
|
3009
|
+
most natural implementation there is, building the next task by spreading the last one. The
|
|
3010
|
+
provider's own state looks right, the claim on the wire is stale, and only the host sees it. An
|
|
3011
|
+
adapter that builds each transition from what is true after it, rather than from what was true
|
|
3012
|
+
before, needs none of these rules named. Two agents were
|
|
3013
|
+
once shown on calls for the better part of an hour while callers queued, because the last message
|
|
3014
|
+
describing the room live was never followed by one saying it had emptied, and every consumer that
|
|
3015
|
+
derived anything from it was wrong in a way that looked like its own bug.
|
|
3016
|
+
|
|
2997
3017
|
**A dialled entry is listed from the moment the dial is placed, not from the answer.** The entry is
|
|
2998
3018
|
what makes `transfer` `cancel` issuable and what `conference` `remove` names, and a destination the
|
|
2999
3019
|
agent cannot call off while it rings is a parked customer with no way back -- which is the very case
|
|
@@ -4307,6 +4327,26 @@ optional part of a task (`task.browsers`, `task.handlingHistory`, `task.leadAssi
|
|
|
4307
4327
|
each event type (`event.task-ended`, …) — and so what a clean `violations` says nothing about.
|
|
4308
4328
|
Nothing there is a violation: an adapter with no team has nothing to exercise. But a fixture with
|
|
4309
4329
|
no tasks exercises no task rule, and a pass over it reads as coverage it is not.
|
|
4330
|
+
|
|
4331
|
+
**A static run never answers a call.** It states a capacity and disconnects, so everything
|
|
4332
|
+
downstream of `answer` -- the room, the stage, media, every phase past `pending`, every dial
|
|
4333
|
+
outcome -- stays in `notExercised` for every adapter, and a rule about a live call is enforced only
|
|
4334
|
+
in each adopter's own tests. `{ drive: true }` closes that: the exercise takes the first task the
|
|
4335
|
+
provider offers through one ordinary lifecycle -- accept it, wait for its media and open it on a
|
|
4336
|
+
softphone, hold and resume where the task offers `hold`, end the call where it offers `endCall`,
|
|
4337
|
+
complete it with a disposition where the agent completes -- and holds every step to the rules a
|
|
4338
|
+
host holds a provider to. Each command is validated against the task as published
|
|
4339
|
+
(`drive.command.*`), each answer for its method, a refusal of a control the task offered is a
|
|
4340
|
+
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
|
|
4343
|
+
commands against whatever platform the adapter is connected to: turn it on against a test backend.
|
|
4344
|
+
|
|
4345
|
+
```ts
|
|
4346
|
+
const driven = await exerciseAdapter(adapter, context, { collectOnly: true, drive: true });
|
|
4347
|
+
expect(driven.violations).toEqual([]);
|
|
4348
|
+
assertReached(driven, ["task.onCall", "task.media", "event.task-ended"]);
|
|
4349
|
+
```
|
|
4310
4350
|
`assertReached(result, subjects)` is the paired assertion: it throws naming every subject the run
|
|
4311
4351
|
never met, so a test that meant to check a roster cannot pass on a fixture that never produced one.
|
|
4312
4352
|
|