@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 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",