@xema/omni-protocol 0.1.52 → 0.1.54
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 +193 -1
- package/guide.md +29 -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,186 @@ 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
|
+
// Media may arrive any time after the accept, before or after the task's own update says
|
|
906
|
+
// in-progress: the event is the provider's word that the audio should attach, never a reply to
|
|
907
|
+
// openMedia, so the drive looks for it from the accept rather than from the last update it read.
|
|
908
|
+
const acceptedAt = drive.events.length;
|
|
909
|
+
// 1. Accept the offer, if it is one.
|
|
910
|
+
if (latestTask().phase === "pending") {
|
|
911
|
+
if (await send({ type: drive.channel === "voice" ? "answer" : "accept" }) === undefined)
|
|
912
|
+
return found;
|
|
913
|
+
if (await updated(t => t.phase !== "pending", "the task leaving pending after it was accepted") === undefined)
|
|
914
|
+
return found;
|
|
915
|
+
}
|
|
916
|
+
// 2. A preview: press Call, which is a dial.
|
|
917
|
+
if (latestTask().phase === "preview") {
|
|
918
|
+
const dialId = `drive-${taskId}`;
|
|
919
|
+
drive.stream.dialled(dialId);
|
|
920
|
+
if (await send({ type: "call", dialId }, dialId) === undefined)
|
|
921
|
+
return found;
|
|
922
|
+
if (await updated(t => t.phase === "in-progress" || t.phase === "completing", "the task leaving preview after Call") === undefined)
|
|
923
|
+
return found;
|
|
924
|
+
}
|
|
925
|
+
if (latestTask().phase === "confirmed") {
|
|
926
|
+
if (await updated(t => t.phase !== "confirmed", "the task leaving confirmed") === undefined)
|
|
927
|
+
return found;
|
|
928
|
+
}
|
|
929
|
+
// 3. On a softphone, the audio arrives and the host opens it.
|
|
930
|
+
let session;
|
|
931
|
+
if (drive.softphone && latestTask().phase === "in-progress") {
|
|
932
|
+
if (latestTask().media !== "started") {
|
|
933
|
+
const started = await waitFor("task-media-started for the driven task", envelope => {
|
|
934
|
+
const event = envelope.event;
|
|
935
|
+
return event.type === "task-media-started" && event.taskId === taskId ? event : undefined;
|
|
936
|
+
}, acceptedAt);
|
|
937
|
+
if (started === undefined)
|
|
938
|
+
return found;
|
|
939
|
+
cursor = Math.max(cursor, started.at);
|
|
940
|
+
}
|
|
941
|
+
const opened = await drive.connection.openMedia?.({ taskId, localAudio: drive.localAudio });
|
|
942
|
+
found.push(...validateResult(opened, "openMedia", "drive.openMedia"));
|
|
943
|
+
if (isRecord(opened) && opened.status === "opened")
|
|
944
|
+
session = opened.session;
|
|
945
|
+
else if (isRecord(opened))
|
|
946
|
+
refuse("drive.openMedia.unavailable", "drive.openMedia", "a softphone login's adapter could not open the call's audio");
|
|
947
|
+
}
|
|
948
|
+
// 4. Hold and resume, where offered.
|
|
949
|
+
if (latestTask().phase === "in-progress" && offers("hold")) {
|
|
950
|
+
if (await send({ type: drive.channel === "chat" ? "pause" : "hold" }) !== undefined) {
|
|
951
|
+
if (await updated(t => t.phase === "paused", "the task pausing after hold") !== undefined) {
|
|
952
|
+
if (await send({ type: "resume" }) !== undefined) {
|
|
953
|
+
await updated(t => t.phase === "in-progress", "the task resuming after resume");
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
// 5. End the call, where the agent may.
|
|
959
|
+
if (drive.channel === "voice" && latestTask().phase === "in-progress" && offers("endCall")) {
|
|
960
|
+
if (await send({ type: "end-call" }) !== undefined) {
|
|
961
|
+
const mediaEnded = await waitFor("task-media-ended after end-call", envelope => {
|
|
962
|
+
const event = envelope.event;
|
|
963
|
+
return event.type === "task-media-ended" && event.taskId === taskId ? event : undefined;
|
|
964
|
+
}, cursor);
|
|
965
|
+
if (mediaEnded !== undefined)
|
|
966
|
+
cursor = mediaEnded.at;
|
|
967
|
+
await updated(t => t.phase === "completing", "the task completing after its media ended");
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
if (typeof session?.close === "function") {
|
|
971
|
+
try {
|
|
972
|
+
session.close();
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
refuse("drive.openMedia.close", "drive.openMedia", "the media session threw on close");
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
// 6. Complete, where the agent completes; otherwise the provider does, and the drive waits for it.
|
|
979
|
+
// A conversation has no media to end and no completing phase to wait for: a chat or an email,
|
|
980
|
+
// and a voice task offering no end-call, is completed from where it stands.
|
|
981
|
+
const completable = latestTask().phase === "completing" || latestTask().phase === "in-progress" || latestTask().phase === "paused";
|
|
982
|
+
if (latestTask().completionMode === "agent-command" && completable) {
|
|
983
|
+
const command = { type: "complete" };
|
|
984
|
+
const dispositions = isRecord(latestTask().capabilities) ? latestTask().capabilities.dispositions : undefined;
|
|
985
|
+
if (isRecord(dispositions) && dispositions.required === true && Array.isArray(dispositions.codes) && isRecord(dispositions.codes[0])) {
|
|
986
|
+
command.disposition = dispositions.codes[0].id;
|
|
987
|
+
}
|
|
988
|
+
if (await send(command) !== undefined)
|
|
989
|
+
await ended();
|
|
990
|
+
}
|
|
991
|
+
else if (latestTask().phase === "completing") {
|
|
992
|
+
await ended();
|
|
993
|
+
}
|
|
994
|
+
return found;
|
|
995
|
+
}
|
|
804
996
|
// A request goes not-requested -> awaiting-decision | granted; a commit goes granted ->
|
|
805
997
|
// starting-after-task | in-effect; work ending goes starting-after-task -> in-effect; a denial,
|
|
806
998
|
// a cancel, an end or a release goes back to not-requested; a placed break arrives in-effect
|
package/guide.md
CHANGED
|
@@ -4127,6 +4127,9 @@ partial patch.
|
|
|
4127
4127
|
The provider's word that the task's audio should now attach. Omni calls `openMedia` on it — and on
|
|
4128
4128
|
a task carried with `media: "started"`, which is how a reconnect snapshot reattaches audio an
|
|
4129
4129
|
earlier event brought — and renders the call as live from that word, never from its own senses. It
|
|
4130
|
+
precedes `openMedia` and is never a reply to it: a provider whose media state comes from the
|
|
4131
|
+
platform, a station going in use the moment a call is answered, sends it then, before any host has
|
|
4132
|
+
opened anything, and `openMedia` has its own answer for what the host did. It
|
|
4130
4133
|
names a task whose work has begun, and it alternates with `task-media-ended`: media that never
|
|
4131
4134
|
started cannot end, so a live call whose provider says nothing about its audio is a provider in
|
|
4132
4135
|
breach, not a state a desk fills in from its own devices.
|
|
@@ -4327,6 +4330,32 @@ optional part of a task (`task.browsers`, `task.handlingHistory`, `task.leadAssi
|
|
|
4327
4330
|
each event type (`event.task-ended`, …) — and so what a clean `violations` says nothing about.
|
|
4328
4331
|
Nothing there is a violation: an adapter with no team has nothing to exercise. But a fixture with
|
|
4329
4332
|
no tasks exercises no task rule, and a pass over it reads as coverage it is not.
|
|
4333
|
+
|
|
4334
|
+
**A static run never answers a call.** It states a capacity and disconnects, so everything
|
|
4335
|
+
downstream of `answer` -- the room, the stage, media, every phase past `pending`, every dial
|
|
4336
|
+
outcome -- stays in `notExercised` for every adapter, and a rule about a live call is enforced only
|
|
4337
|
+
in each adopter's own tests. `{ drive: true }` closes that: the exercise takes the first task the
|
|
4338
|
+
provider offers through one ordinary lifecycle -- accept it, wait for its media and open it on a
|
|
4339
|
+
softphone, hold and resume where the task offers `hold`, end the call where it offers `endCall`,
|
|
4340
|
+
complete it with a disposition where the agent completes -- and holds every step to the rules a
|
|
4341
|
+
host holds a provider to. Each command is validated against the task as published
|
|
4342
|
+
(`drive.command.*`), each answer for its method, a refusal of a control the task offered is a
|
|
4343
|
+
violation (`drive.command.failed`), and an event the provider owes and never sends is one too
|
|
4344
|
+
(`drive.timeout`, after `driveTimeoutMs`, 5000 by default). A task the agent completes is
|
|
4345
|
+
completed from wherever it stands once the drive has nothing left to do on it -- from `completing`
|
|
4346
|
+
after an `end-call`, or from `in-progress` where there is no call to end, which is every chat and
|
|
4347
|
+
email and a voice task offering no `endCall` -- so a conversation reaches its end as a call does.
|
|
4348
|
+
The drive stops where the task offers no way on and says nothing about what it could not reach. A
|
|
4349
|
+
softphone adapter written for a browser needs its media APIs supplied by whatever runs the
|
|
4350
|
+
harness: the drive opens media outside a browser, and a provider that quietly stopped carrying audio
|
|
4351
|
+
where `AudioContext` was missing would be lying about the one thing the channel is for. It is off by default because it issues
|
|
4352
|
+
commands against whatever platform the adapter is connected to: turn it on against a test backend.
|
|
4353
|
+
|
|
4354
|
+
```ts
|
|
4355
|
+
const driven = await exerciseAdapter(adapter, context, { collectOnly: true, drive: true });
|
|
4356
|
+
expect(driven.violations).toEqual([]);
|
|
4357
|
+
assertReached(driven, ["task.onCall", "task.media", "event.task-ended"]);
|
|
4358
|
+
```
|
|
4330
4359
|
`assertReached(result, subjects)` is the paired assertion: it throws naming every subject the run
|
|
4331
4360
|
never met, so a test that meant to check a roster cannot pass on a fixture that never produced one.
|
|
4332
4361
|
|