@xema/omni-protocol 0.1.52 → 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/guide.md +20 -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/guide.md
CHANGED
|
@@ -4327,6 +4327,26 @@ optional part of a task (`task.browsers`, `task.handlingHistory`, `task.leadAssi
|
|
|
4327
4327
|
each event type (`event.task-ended`, …) — and so what a clean `violations` says nothing about.
|
|
4328
4328
|
Nothing there is a violation: an adapter with no team has nothing to exercise. But a fixture with
|
|
4329
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
|
+
```
|
|
4330
4350
|
`assertReached(result, subjects)` is the paired assertion: it throws naming every subject the run
|
|
4331
4351
|
never met, so a test that meant to check a roster cannot pass on a fixture that never produced one.
|
|
4332
4352
|
|