@xema/omni-protocol 0.1.101 → 0.1.102

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 CHANGED
@@ -24,7 +24,7 @@ const STATE_SUBJECTS = [
24
24
  "break.reasons",
25
25
  "break.forced",
26
26
  "team.members",
27
- "team.requests",
27
+ "team.request",
28
28
  "team.tasks",
29
29
  "team.listening",
30
30
  "team.shift",
@@ -37,7 +37,8 @@ const STATE_SUBJECTS = [
37
37
  const EVENT_TYPES = {
38
38
  snapshot: true, "transport-status": true, "break-state": true, "task-offered": true, "task-updated": true,
39
39
  "task-audio-started": true, "task-audio-ended": true, "task-ended": true, "dial-outcome": true, announcement: true, "queue-summary": true, diagnostic: true,
40
- "team-updated": true, "contacts-updated": true, "calendar-updated": true,
40
+ "team-updated": true, "team-member-updated": true, "team-member-removed": true, "team-policies-updated": true,
41
+ "contacts-updated": true, "calendar-updated": true,
41
42
  };
42
43
  const CONTRACT_SUBJECTS = [
43
44
  ...STATE_SUBJECTS,
@@ -88,26 +89,31 @@ function observeBreak(value, seen) {
88
89
  if (value.forced !== undefined)
89
90
  seen.add("break.forced");
90
91
  }
92
+ function observeMember(member, seen) {
93
+ if (!isRecord(member))
94
+ return;
95
+ if (some(member.tasks))
96
+ seen.add("team.tasks");
97
+ if (member.listening !== undefined)
98
+ seen.add("team.listening");
99
+ if (member.shift !== undefined)
100
+ seen.add("team.shift");
101
+ if (member.request !== undefined)
102
+ seen.add("team.request");
103
+ }
104
+ function observePolicies(value, seen) {
105
+ if (isRecord(value) && Object.keys(value).length > 0)
106
+ seen.add("team.policies");
107
+ }
91
108
  function observeTeam(value, seen) {
92
109
  if (!isRecord(value))
93
110
  return;
94
111
  if (some(value.members))
95
112
  seen.add("team.members");
96
- if (some(value.requests))
97
- seen.add("team.requests");
98
113
  if (Array.isArray(value.members))
99
- for (const member of value.members) {
100
- if (!isRecord(member))
101
- continue;
102
- if (some(member.tasks))
103
- seen.add("team.tasks");
104
- if (member.listening !== undefined)
105
- seen.add("team.listening");
106
- if (member.shift !== undefined)
107
- seen.add("team.shift");
108
- }
109
- if (isRecord(value.policies) && Object.keys(value.policies).length > 0)
110
- seen.add("team.policies");
114
+ for (const member of value.members)
115
+ observeMember(member, seen);
116
+ observePolicies(value.policies, seen);
111
117
  }
112
118
  function observeSnapshot(value, seen) {
113
119
  if (!isRecord(value))
@@ -141,6 +147,13 @@ function observeEvent(envelope, seen) {
141
147
  case "team-updated":
142
148
  observeTeam(event.team, seen);
143
149
  break;
150
+ case "team-member-updated":
151
+ seen.add("team.members");
152
+ observeMember(event.member, seen);
153
+ break;
154
+ case "team-policies-updated":
155
+ observePolicies(event.policies, seen);
156
+ break;
144
157
  case "contacts-updated":
145
158
  if (some(event.contacts))
146
159
  seen.add("contacts");
@@ -172,6 +185,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
172
185
  const seen = new Set();
173
186
  const stream = new TaskStream();
174
187
  const breaks = new BreakStream();
188
+ const team = new TeamStream();
175
189
  let seeded = false;
176
190
  const duringRead = [];
177
191
  let lockedPartySeen = false;
@@ -211,9 +225,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
211
225
  throw new Error("unreachable: the exercise has an authenticated login");
212
226
  return login;
213
227
  };
228
+ // Off until the application has said otherwise: the provider assumes nothing of the team feature.
229
+ let leadFeaturesOn = false;
214
230
  const reader = () => ({
215
231
  self: current().identity.id,
216
232
  capabilities: current().capabilities,
233
+ leadFeatures: leadFeaturesOn,
217
234
  loginId: context.loginId,
218
235
  autoAcceptTasks: context.autoAcceptTasks,
219
236
  locked: options.lockedValues,
@@ -392,7 +409,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
392
409
  // Cross-event rules apply once the stream has a beginning: the connect snapshot. An event
393
410
  // delivered while that snapshot is read is held until it lands, and read then against it.
394
411
  if (seeded)
395
- violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
412
+ violations.push(...stream.apply(envelope), ...breaks.apply(envelope), ...team.apply(envelope));
396
413
  else
397
414
  duringRead.push(envelope);
398
415
  events.push(envelope);
@@ -487,6 +504,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
487
504
  await snapshotRead(live, snapshot, "snapshot");
488
505
  stream.seed(snapshot);
489
506
  violations.push(...breaks.seed(snapshot));
507
+ team.seed(snapshot);
490
508
  seeded = true;
491
509
  // A snapshot supersedes what it restates and nothing else. Of the events held during the read,
492
510
  // a state-replacing kind is dropped, since the snapshot carries that state and must account for
@@ -500,7 +518,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
500
518
  for (const held of duringRead) {
501
519
  const event = held.event;
502
520
  if (!SUPERSEDED_BY_A_SNAPSHOT.has(String(event.type))) {
503
- violations.push(...stream.apply(held), ...breaks.apply(held));
521
+ violations.push(...stream.apply(held), ...breaks.apply(held), ...team.apply(held));
504
522
  continue;
505
523
  }
506
524
  // Evaluated where a superseded event was held: with none, the question was never asked.
@@ -547,6 +565,42 @@ export async function exerciseAdapter(adapter, context, options = {}) {
547
565
  message: `the host sent ${context.timeZone} at connect and the identity says ${String(current().identity.timeZone)}: the provider republishes the agent's time zone on the identity`,
548
566
  });
549
567
  }
568
+ // The provider assumes nothing of the team feature: the application says on every connect,
569
+ // and once it has said on, the whole team is owed, once. Before that nothing of the team
570
+ // reaches the lead, which the reader held the connect snapshot to.
571
+ if (current().capabilities.lead === true && typeof live.executeTeam === "function") {
572
+ const from = events.length;
573
+ let switched;
574
+ // On from the moment the application says so: the provider may answer with the team at once.
575
+ leadFeaturesOn = true;
576
+ try {
577
+ switched = await live.executeTeam({ command: { type: "lead-features", enabled: true } });
578
+ }
579
+ catch (error) {
580
+ leadFeaturesOn = false;
581
+ violations.push({ rule: "team.switch.rejected", path: "connection.executeTeam", message: `lead-features on was rejected: ${String(error)}` });
582
+ }
583
+ if (switched !== undefined) {
584
+ violations.push(...validateResult(switched, "executeTeam", "connection.executeTeam"));
585
+ ruleEvaluated("team.required");
586
+ const ms = options.driveTimeoutMs ?? 5000;
587
+ const isBaseline = (envelope) => isRecord(envelope?.event) && envelope.event.type === "team-updated";
588
+ const baseline = events.slice(from).some(isBaseline) || await new Promise(resolve => {
589
+ const timer = setTimeout(() => { waiters.delete(waiter); resolve(false); }, ms);
590
+ const waiter = (envelope) => {
591
+ if (!isBaseline(envelope))
592
+ return;
593
+ clearTimeout(timer);
594
+ waiters.delete(waiter);
595
+ resolve(true);
596
+ };
597
+ waiters.add(waiter);
598
+ });
599
+ if (!baseline)
600
+ violations.push({ rule: "team.required", path: "event.team",
601
+ 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` });
602
+ }
603
+ }
550
604
  if (options.drive) {
551
605
  const localAudio = isRecord(first) && isRecord(first.audio) && isRecord(first.audio.input) && first.audio.input.status === "available"
552
606
  ? first.audio.input.localAudio : undefined;
@@ -555,8 +609,15 @@ export async function exerciseAdapter(adapter, context, options = {}) {
555
609
  everHeldForTask: (taskId) => [...watched.everHeld].some(key => namesTask(key, taskId)), snapshotRead,
556
610
  timeoutMs: options.driveTimeoutMs ?? 5000,
557
611
  context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, handOver,
558
- streams: { stream, breaks },
612
+ streams: { stream, breaks, team },
559
613
  }));
614
+ // Every offer is owed an ending. The drive ends the call it drove; anything else the
615
+ // provider offered and left open is an assignment whose ending never came.
616
+ ruleEvaluated("stream.taskOffered.unended");
617
+ for (const id of stream.unended()) {
618
+ violations.push({ rule: "stream.taskOffered.unended", path: "event.task-ended",
619
+ message: `${id} was offered and never ended: every offer is owed a task-ended, whatever became of the call` });
620
+ }
560
621
  }
561
622
  // Capacity supersedes rather than accumulates, so a decrease is as ordinary as an increase: the
562
623
  // host raises it, lowers it, and takes it away, and the provider takes each as the ceiling it is.
@@ -641,7 +702,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
641
702
  * its shape.
642
703
  */
643
704
  function publishesUserIds(snapshot) {
644
- if (snapshot?.break?.forced?.by !== undefined)
705
+ if (snapshot?.break?.forced?.by !== undefined && snapshot.break.forced.by !== "provider")
645
706
  return true;
646
707
  if (teamNamesUsers(snapshot?.team))
647
708
  return true;
@@ -654,15 +715,13 @@ function userIdsIn(snapshot) {
654
715
  const ids = new Set();
655
716
  const add = (value) => { if (typeof value === "string" && value.length > 0)
656
717
  ids.add(value); };
657
- add(snapshot?.break?.forced?.by);
718
+ if (snapshot?.break?.forced?.by !== "provider")
719
+ add(snapshot?.break?.forced?.by);
658
720
  const team = snapshot?.team;
659
721
  if (isRecord(team)) {
660
722
  for (const member of Array.isArray(team.members) ? team.members : [])
661
723
  if (isRecord(member))
662
724
  add(member.id);
663
- for (const request of Array.isArray(team.requests) ? team.requests : [])
664
- if (isRecord(request))
665
- add(request.memberId);
666
725
  }
667
726
  for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
668
727
  const t = task;
@@ -702,7 +761,7 @@ function userIdsIn(snapshot) {
702
761
  }
703
762
  return [...ids];
704
763
  }
705
- const teamNamesUsers = (team) => isRecord(team) && (some(team.members) || some(team.requests));
764
+ const teamNamesUsers = (team) => isRecord(team) && some(team.members);
706
765
  const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.history) && Array.isArray(task.history.steps) && task.history.steps.some(step => isRecord(step) && step.by !== undefined)) ||
707
766
  (isRecord(task.leadAssist) && task.leadAssist.leadId !== undefined) ||
708
767
  isRecord(task.takenOver));
@@ -713,10 +772,12 @@ function eventNamesUsers(envelope) {
713
772
  return false;
714
773
  switch (event.type) {
715
774
  case "snapshot": return publishesUserIds(event.snapshot);
716
- case "break-state": return isRecord(event.break) && isRecord(event.break.forced) && event.break.forced.by !== undefined;
775
+ case "break-state": return isRecord(event.break) && isRecord(event.break.forced) && event.break.forced.by !== undefined && event.break.forced.by !== "provider";
717
776
  case "task-offered":
718
777
  case "task-updated": return taskNamesUsers(event.task);
719
778
  case "team-updated": return teamNamesUsers(event.team);
779
+ case "team-member-updated": return isRecord(event.member) && event.member.id !== undefined;
780
+ case "team-member-removed": return true;
720
781
  default: return false;
721
782
  }
722
783
  }
@@ -1001,7 +1062,8 @@ const REACHABLE_PHASES = {
1001
1062
  */
1002
1063
  export const SUPERSEDED_BY_A_SNAPSHOT = new Set([
1003
1064
  "snapshot", "transport-status", "break-state", "task-offered", "task-updated", "task-ended",
1004
- "task-audio-started", "task-audio-ended", "team-updated", "contacts-updated", "calendar-updated",
1065
+ "task-audio-started", "task-audio-ended", "team-updated", "team-member-updated", "team-member-removed", "team-policies-updated",
1066
+ "contacts-updated", "calendar-updated",
1005
1067
  ]);
1006
1068
  export class TaskStream {
1007
1069
  tasks = new Map();
@@ -1010,8 +1072,10 @@ export class TaskStream {
1010
1072
  // a task that is open.
1011
1073
  assignments = new Set();
1012
1074
  endedAssignments = new Set();
1075
+ /** Every assignment a `task-offered` introduced, as against one a snapshot carried: each is owed its ending. */
1076
+ offered = new Set();
1013
1077
  // Every dial the stream can place an outcome against: one the host said it placed, or one a
1014
- // task carried on `onCall` or in its record -- which is how a dial made before a transfer is known
1078
+ // task carried on `onCall` or in its record -- which is how a dial made before a take-over is known
1015
1079
  // to whoever holds the task now. `answered` or `ended` once its outcome arrived, since it comes once.
1016
1080
  dials = new Map();
1017
1081
  /** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
@@ -1045,6 +1109,8 @@ export class TaskStream {
1045
1109
  }
1046
1110
  /** How many tasks the stream currently holds open. */
1047
1111
  openCount() { return this.tasks.size; }
1112
+ /** The assignments an offer introduced that are still open: each was owed a `task-ended` that has not come. */
1113
+ unended() { return [...this.offered].filter(id => this.tasks.has(id)); }
1048
1114
  /** Whether an offered task is a dial arriving -- the host's own call, a connect-back -- which counts against no capacity: an entry on its call carrying a dial. */
1049
1115
  static carriesDial(task) {
1050
1116
  if (!isRecord(task) || !Array.isArray(task.onCall))
@@ -1174,6 +1240,7 @@ export class TaskStream {
1174
1240
  refuse("stream.taskOffered.duplicate", `${at}.task.assignmentId`, known !== undefined ? `${id} is already on the stream; an offer introduces an assignment once` : `${id} was already an assignment on this stream: an assignment is named once per offer and never reused, whatever the platform does with its own handle`);
1175
1241
  }
1176
1242
  this.assignments.add(id);
1243
+ this.offered.add(id);
1177
1244
  this.tasks.set(id, TaskStream.stated(event.task));
1178
1245
  this.noteDials(event.task);
1179
1246
  break;
@@ -1219,7 +1286,7 @@ export class TaskStream {
1219
1286
  }
1220
1287
  // A task completes after its audio ends, never around it: an update that moves a task to
1221
1288
  // completing while the stream holds its audio as started is a call whose audio never ended,
1222
- // whoever caused the ending -- the drive's end-call, a transfer, the provider's own hand.
1289
+ // whoever caused the ending -- the drive's end-call, a take-over, the provider's own hand.
1223
1290
  {
1224
1291
  const to = isRecord(event.task) ? String(event.task.phase) : "";
1225
1292
  if (to === "completing" && known.audio === "started" && known.phase !== "completing") {
@@ -1320,8 +1387,9 @@ export class TaskStream {
1320
1387
  // that completes the task itself with an allowance to run has to have started the clock:
1321
1388
  // completed from in-progress, the allowance it stated was never given.
1322
1389
  ruleEvaluated("stream.taskEnded.unwrapped");
1390
+ // An agent's own complete cuts the wrap short from wherever the task stands, so that ending says by agent and is not this.
1323
1391
  if (known.channel !== "voice" && known.completionMode === "provider-automatic" && known.wrapAllowance !== undefined && known.wrapAllowance > 0
1324
- && known.phase !== "completing" && isRecord(event.outcome) && event.outcome.type === "completed") {
1392
+ && known.phase !== "completing" && isRecord(event.outcome) && event.outcome.type === "completed" && event.outcome.by === "provider") {
1325
1393
  refuse("stream.taskEnded.unwrapped", `${at}.outcome`, `${id} was completed from ${known.phase} with a wrap allowance of ${known.wrapAllowance}s under provider-automatic: off voice, completing is the provider's word that interaction ended and the allowance's start, and it was never published`);
1326
1394
  }
1327
1395
  this.endedAssignments.add(id);
@@ -1441,6 +1509,7 @@ async function driveOneCall(drive) {
1441
1509
  await drive.snapshotRead(second, snapshot, "drive.reload.snapshot");
1442
1510
  found.push(...drive.streams.stream.resync(snapshot, "drive.reload.snapshot"));
1443
1511
  found.push(...drive.streams.breaks.seed(snapshot));
1512
+ drive.streams.team.seed(snapshot);
1444
1513
  const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
1445
1514
  ? snapshot.tasks.find(task => isRecord(task) && task.assignmentId === taskId) : undefined;
1446
1515
  ruleEvaluated("drive.reload.snapshot");
@@ -1779,11 +1848,12 @@ async function driveOneCall(drive) {
1779
1848
  refuse("drive.openAudio.close", "drive.openAudio", "the audio session threw on close");
1780
1849
  }
1781
1850
  }
1782
- // 6. Complete, where the agent completes; otherwise the provider does, and the drive waits for it.
1783
- // A conversation has no audio to end and no completing phase to wait for: a chat or an email,
1784
- // and a voice task offering no end-call, is completed from where it stands.
1851
+ // 6. Complete, under either mode: the agent may finish early and the provider is free to end at
1852
+ // once. Only a task with no wrap at all has nothing to complete, and there the drive waits for the
1853
+ // provider. A conversation has no audio to end and no completing phase to wait for: a chat or an
1854
+ // email, and a voice task offering no end-call, is completed from where it stands.
1785
1855
  const completable = latestTask().phase === "completing" || latestTask().phase === "in-progress" || latestTask().phase === "paused";
1786
- if (latestTask().completionMode === "agent-command" && completable) {
1856
+ if (latestTask().wrapAllowance !== 0 && completable) {
1787
1857
  const command = { type: "complete" };
1788
1858
  const outcomes = isRecord(latestTask().capabilities) ? latestTask().capabilities.outcomes : undefined;
1789
1859
  if (isRecord(outcomes) && outcomes.required === true && Array.isArray(outcomes.codes) && isRecord(outcomes.codes[0])) {
@@ -1863,6 +1933,63 @@ async function driveOneCall(drive) {
1863
1933
  // preserves the committed break. A forced break arrives on-break
1864
1934
  // with `forced`. Nothing else is a move the guide describes.
1865
1935
  /** What a stream has said about the agent's break, and the moves it may not make. */
1936
+ /**
1937
+ * The team as the events left it: whole on `team-updated` and on any snapshot carrying one, then
1938
+ * one member at a time. A member event before the whole team, or a removal of a member the team
1939
+ * never carried, is a change to a team the lead was never given.
1940
+ */
1941
+ export class TeamStream {
1942
+ members;
1943
+ static ids(team) {
1944
+ if (!isRecord(team) || !Array.isArray(team.members))
1945
+ return undefined;
1946
+ return new Set(team.members.filter(isRecord).map(member => String(member.id)));
1947
+ }
1948
+ /** A snapshot carrying a team is the team whole; one carrying none leaves the lead with none. */
1949
+ seed(snapshot) {
1950
+ this.members = TeamStream.ids(isRecord(snapshot) ? snapshot.team : undefined);
1951
+ }
1952
+ /** Whether the team currently carries this member, as the events left it. */
1953
+ has(id) { return this.members?.has(id) === true; }
1954
+ apply(envelope, path = "event") {
1955
+ const event = isRecord(envelope) ? envelope.event : undefined;
1956
+ if (!isRecord(event))
1957
+ return [];
1958
+ const at = `${path}.event`;
1959
+ switch (event.type) {
1960
+ case "snapshot":
1961
+ this.seed(event.snapshot);
1962
+ return [];
1963
+ case "team-updated":
1964
+ this.members = TeamStream.ids(event.team);
1965
+ return [];
1966
+ case "team-member-updated":
1967
+ case "team-member-removed":
1968
+ case "team-policies-updated": {
1969
+ ruleEvaluated("stream.team.baseline");
1970
+ if (this.members === undefined) {
1971
+ return [{ rule: "stream.team.baseline", path: `${at}.type`,
1972
+ message: `${event.type} arrived before the team: team-updated carries the whole team first, and it changes one member at a time after that` }];
1973
+ }
1974
+ if (event.type === "team-member-updated") {
1975
+ if (isRecord(event.member) && typeof event.member.id === "string")
1976
+ this.members.add(event.member.id);
1977
+ return [];
1978
+ }
1979
+ if (event.type === "team-member-removed") {
1980
+ ruleEvaluated("stream.teamMember.unknown");
1981
+ const id = String(event.memberId);
1982
+ if (!this.members.has(id)) {
1983
+ return [{ rule: "stream.teamMember.unknown", path: `${at}.memberId`, message: `${id} is not a member the team carried: a removal takes off somebody the lead was given` }];
1984
+ }
1985
+ this.members.delete(id);
1986
+ }
1987
+ return [];
1988
+ }
1989
+ default: return [];
1990
+ }
1991
+ }
1992
+ }
1866
1993
  export class BreakStream {
1867
1994
  state;
1868
1995
  recovery = true;
@@ -98,8 +98,9 @@ export interface ReaderContext {
98
98
  /** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
99
99
  loginId?: string;
100
100
  /**
101
- * Whether the lead has the team feature on, as the application last told the provider. On by
102
- * default for a lead; off, no team member list is owed or expected. Unknown to a caller without it.
101
+ * Whether the lead has the team feature on, as the application last told the provider. The
102
+ * provider assumes nothing until told: on, a snapshot carries the team; off, nothing of the
103
+ * team is owed or expected. Unknown to a caller without it, and then unchecked either way.
103
104
  */
104
105
  leadFeatures?: boolean;
105
106
  /** The provider's channel, for a member's task that states none. Filled from the manifest by `validateSnapshot` and `validateEventEnvelope`. */
@@ -114,6 +115,8 @@ export interface ReaderContext {
114
115
  autoAcceptTasks?: boolean;
115
116
  }
116
117
  export declare function validateTeamMembers(teamMembers: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
118
+ /** One member as the lead sees them, whole: on the list, and on `team-member-updated` alone. */
119
+ export declare function validateTeamMember(member: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
117
120
  export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
118
121
  export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
119
122
  /**
@@ -143,7 +146,7 @@ export declare function validateTaskCommandRequest(request: unknown, task: unkno
143
146
  /**
144
147
  * What a command needs to be issuable, checked against the task it names: the capability the
145
148
  * guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
146
- * consulted entry, a lead requested, somebody else on the call. Without a task only the command's
149
+ * lead requested, somebody else on the call. Without a task only the command's
147
150
  * own shape is checked. A host validates before sending, and an adapter before acting: a command
148
151
  * for a control the task never offered is the host's error, and this names it.
149
152
  */