@xema/omni-protocol 0.1.31 → 0.1.33

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/index.d.ts CHANGED
@@ -26,6 +26,12 @@ export type IsoTimestamp = string;
26
26
  export type UserId = string;
27
27
  /** A non-empty, opaque task identifier unique within one provider. Scope it with `taskKey()`. */
28
28
  export type TaskId = string;
29
+ /**
30
+ * The host's identity for one dial, minted before the command leaves and unique across the
31
+ * installation. A dial is the one command whose outcome arrives later and apart, so the provider
32
+ * names this on the outcome and the host has already placed it. Never shown to the agent.
33
+ */
34
+ export type DialId = string;
29
35
  /** A non-negative whole number of seconds. */
30
36
  export type DurationSeconds = number;
31
37
  /** New channels require a later protocol version. */
@@ -68,6 +74,13 @@ export type DialDestinations = "contacts-only" | "any-number";
68
74
  export interface DialCapability {
69
75
  destinations: DialDestinations;
70
76
  }
77
+ /**
78
+ * How a dial ended, as the switch distinguishes it. `answered` is the one success; the rest are
79
+ * the ways a destination was not reached. A manifest declares which of these its platform can
80
+ * tell apart (`dialOutcomes`), and a `dial-outcome` carries only a declared one.
81
+ */
82
+ export type DialOutcome = "answered" | "busy" | "no-answer" | "unreachable" | "rejected";
83
+ export declare const DIAL_OUTCOMES: readonly ["answered", "busy", "no-answer", "unreachable", "rejected"];
71
84
  /** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
72
85
  export declare const IDLE_CAPABILITIES: readonly ["dial", "personalBrowser", "calendar", "contacts"];
73
86
  export type IdleCapability = (typeof IDLE_CAPABILITIES)[number];
@@ -113,6 +126,18 @@ export interface Manifest<C extends Channel = Channel> {
113
126
  taskTypePresentation?: Record<string, TaskTypePresentation>;
114
127
  /** The organisation's whole ladder, stated outright, `person` included. Omitted for the typical four, `DEFAULT_LEVELS`. */
115
128
  orgLevels?: LevelDeclaration[];
129
+ /**
130
+ * The outcomes this platform distinguishes when a dial ends, `answered` and at least one way of
131
+ * not reaching the destination. Required of a provider that dials at all -- an idle dialpad, or
132
+ * a task that may transfer, conference, or call back -- and forbidden off voice, where nothing dials.
133
+ */
134
+ dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
135
+ /**
136
+ * The provider takes running reports of a host-performed step -- `recordStep` with `seconds`
137
+ * so far and no `ended`. Absent, the host sends exactly two reports per leg, when it began and
138
+ * when it ended, and a running report is refused: what a provider never asked for never crosses.
139
+ */
140
+ runningStepReports?: true;
116
141
  }
117
142
  export interface SecretStore {
118
143
  get(key: string): Promise<string | undefined>;
@@ -493,9 +518,33 @@ export type TaskAttribute = TaskAttributeBase & ({
493
518
  at: IsoTimestamp;
494
519
  });
495
520
  export type HandlingStep = "queued" | "offered" | "answered" | "held" | "muted" | "transferred" | "conferenced" | "unanswered";
521
+ /**
522
+ * The call record: the steps that brought the task here, one entry per occurrence and oldest
523
+ * first, and what they add up to before this agent -- stated by the provider from its own record,
524
+ * never summed by a desk from instants. Each total is present when the provider knows it and
525
+ * absent when it does not; a plausible nought is the fallback the no-fallbacks rule forbids. The
526
+ * record rides on the task and is replaced with it, so a late entry corrects the sums.
527
+ */
528
+ export interface TaskHandlingHistory {
529
+ steps: TaskHandlingStep[];
530
+ /** Seconds others spent handling it before this agent. */
531
+ handleSeconds?: DurationSeconds;
532
+ /** Seconds the caller spent on hold at others' hands. */
533
+ holdSeconds?: DurationSeconds;
534
+ /** Seconds waiting before anyone answered. */
535
+ queueSeconds?: DurationSeconds;
536
+ /** How many times the task changed hands. */
537
+ transfers?: number;
538
+ }
496
539
  export interface TaskHandlingStep {
497
540
  step: HandlingStep;
498
541
  at: IsoTimestamp;
542
+ /**
543
+ * On a step that dialled -- `transferred`, `conferenced`, `unanswered` -- the host's identity for
544
+ * that dial when a host placed it, and the address it went to. Neither belongs on any other step.
545
+ */
546
+ dialId?: DialId;
547
+ destination?: string;
499
548
  /**
500
549
  * Reported, never derived. An entry may be written while its leg is still running, so there is
501
550
  * no end to subtract from -- and omitted is the honest report of that, where nought would
@@ -523,16 +572,29 @@ export type TaskCompletion = {
523
572
  completionMode: "provider-automatic";
524
573
  wrapAllowance: DurationSeconds;
525
574
  };
575
+ export type OnCallRole = "party" | "agent" | "consulted" | "conferenced";
576
+ export declare const ON_CALL_ROLES: readonly ["party", "agent", "consulted", "conferenced"];
526
577
  /**
527
- * A consultation in progress on a task: who is being consulted, and since when where the provider
528
- * records it. Present between `transfer` `consult` and whichever of `complete` or `cancel`
529
- * follows; its presence is what makes those two issuable.
578
+ * Somebody on the call right now, as the provider states it: who, since when, and whether they
579
+ * are held aside. `party` is the customer; `agent` a person, by user id; `consulted` and
580
+ * `conferenced` someone a dial brought in, carrying the dial when a host placed it and the address
581
+ * either way. `label` names a destination -- a person, a queue -- not a phrase. At most one
582
+ * `party` and one `consulted`: the consult commands name neither because there is exactly one.
530
583
  */
531
- export interface TaskConsultation {
584
+ export type OnCall = {
585
+ since: IsoTimestamp;
586
+ held?: true;
587
+ } & ({
588
+ role: "party";
589
+ } | {
590
+ role: "agent";
591
+ userId: UserId;
592
+ } | {
593
+ role: "consulted" | "conferenced";
532
594
  destination: string;
595
+ dialId?: DialId;
533
596
  label?: string;
534
- since?: IsoTimestamp;
535
- }
597
+ });
536
598
  /**
537
599
  * The agent's request for a lead, from asking until the lead leaves or the request ends.
538
600
  * `requested` while nobody has joined; `joined`, with `leadId`, once somebody has.
@@ -617,14 +679,14 @@ export type Task<C extends Channel = Channel> = {
617
679
  /** The identifier an agent reads back to a customer, where the provider has one. */
618
680
  reference?: string;
619
681
  attributes?: TaskAttribute[];
620
- handlingHistory?: TaskHandlingStep[];
682
+ handlingHistory?: TaskHandlingHistory;
621
683
  } & TaskCompletion & (C extends "voice" ? {
622
- consultation?: TaskConsultation;
684
+ onCall?: OnCall[];
623
685
  lead?: TaskLead;
624
686
  assisting?: TaskAssisting;
625
687
  media?: TaskMediaState;
626
688
  } : {
627
- consultation?: never;
689
+ onCall?: never;
628
690
  lead?: never;
629
691
  assisting?: never;
630
692
  media?: never;
@@ -684,13 +746,15 @@ export type VoiceTaskCommand = {
684
746
  } | {
685
747
  type: "disconnect";
686
748
  }
687
- /** Issuable only in `completing`, under the `callback` capability. Carries no destination. */
749
+ /** Issuable only in `completing`, under the `callback` capability. Dials the party's own number, so it names none. */
688
750
  | {
689
751
  type: "callback";
752
+ dialId: DialId;
690
753
  }
691
754
  /** Blind: hand the customer to `destination` with nobody consulted. Gated by `blindTransfer`. */
692
755
  | {
693
756
  type: "transfer";
757
+ dialId: DialId;
694
758
  destination: string;
695
759
  action?: never;
696
760
  }
@@ -698,14 +762,15 @@ export type VoiceTaskCommand = {
698
762
  | {
699
763
  type: "transfer";
700
764
  action: "consult";
765
+ dialId: DialId;
701
766
  destination: string;
702
767
  }
703
- /** Hand the customer to the consulted destination and leave. Needs `Task.consultation`. */
768
+ /** Hand the customer to the consulted destination and leave. Needs a `consulted` entry on `Task.onCall`. */
704
769
  | {
705
770
  type: "transfer";
706
771
  action: "complete";
707
772
  }
708
- /** Drop the consulted destination and return to the customer. Needs `Task.consultation`. */
773
+ /** Drop the consulted destination and return to the customer. Needs a `consulted` entry on `Task.onCall`. */
709
774
  | {
710
775
  type: "transfer";
711
776
  action: "cancel";
@@ -730,10 +795,19 @@ export type VoiceTaskCommand = {
730
795
  | {
731
796
  type: "lead";
732
797
  action: "leave";
733
- } | {
798
+ }
799
+ /** Dial `destination` into the call. Gated by `conference`. */
800
+ | {
801
+ type: "conference";
802
+ action: "add";
803
+ dialId: DialId;
804
+ destination: string;
805
+ }
806
+ /** Drop the `conferenced` entry on `Task.onCall` with this destination. */
807
+ | {
734
808
  type: "conference";
735
- participant: string;
736
- action: "add" | "remove";
809
+ action: "remove";
810
+ destination: string;
737
811
  } | {
738
812
  type: "recording";
739
813
  action: "start" | "pause" | "resume" | "stop";
@@ -771,22 +845,33 @@ export interface TaskCommandRequest<C extends Channel = Channel> {
771
845
  /**
772
846
  * `applied` rather than a verb per command: the command travels in the request, so
773
847
  * `execute({ command: { type: "hold" } })` returning `applied` already says the hold applied.
848
+ * A command that dials answers `dialling` instead, restating the host's `dialId`: accepted, the
849
+ * call being placed, and the outcome still to come on `dial-outcome`.
774
850
  *
775
851
  * A promise that rejects with no result means *unknown*, not `failed`. Omni does not retry --
776
852
  * no adapter on the agent's PC can make a repeat safe -- and the next snapshot shows what the
777
- * provider did. A command therefore carries no key of Omni's making.
853
+ * provider did. A command therefore carries no retry key of Omni's making; a `dialId` is not
854
+ * one, it is how an outcome arriving later is placed.
778
855
  */
779
856
  export type TaskCommandResult = {
780
857
  status: "applied";
858
+ } | {
859
+ status: "dialling";
860
+ dialId: DialId;
781
861
  } | {
782
862
  status: "failed";
783
863
  failure: ProtocolFailure;
784
864
  };
865
+ /** The dial a command places, by the host's identity for it; `undefined` for a command that dials nothing. */
866
+ export declare function commandDialId(command: TaskCommand): DialId | undefined;
785
867
  export interface DialRequest {
868
+ dialId: DialId;
786
869
  destination: string;
787
870
  }
871
+ /** `dialling` says accepted and being placed, nothing more; the outcome arrives on `dial-outcome`. */
788
872
  export type DialResult = {
789
- status: "dialled";
873
+ status: "dialling";
874
+ dialId: DialId;
790
875
  } | {
791
876
  status: "failed";
792
877
  failure: ProtocolFailure;
@@ -1025,6 +1110,31 @@ export type SetPreferenceRequest = {
1025
1110
  id: PreferenceId;
1026
1111
  inherit: true;
1027
1112
  };
1113
+ /**
1114
+ * The host's report of a handling leg it performed itself -- a mute, which Omni does rather than
1115
+ * the provider -- so the provider's record has an account of it. Keyed by `step` and `at`: the
1116
+ * same entry is reported when it begins, as often as the host cares to while it runs, and once
1117
+ * more with `ended`, when `seconds` is the final duration. The host is the authority for the
1118
+ * legs it performs, so `seconds` may say how long so far at any time; the end is stated, never
1119
+ * inferred from a number's presence. What the adapter forwards upstream, and how often, is its own.
1120
+ */
1121
+ export type HandlingReport = {
1122
+ taskId: TaskId;
1123
+ step: HandlingStep;
1124
+ at: IsoTimestamp;
1125
+ } & ({
1126
+ ended: true;
1127
+ seconds: DurationSeconds;
1128
+ } | {
1129
+ ended?: never;
1130
+ seconds?: DurationSeconds;
1131
+ });
1132
+ export type HandlingReportResult = {
1133
+ status: "recorded";
1134
+ } | {
1135
+ status: "failed";
1136
+ failure: ProtocolFailure;
1137
+ };
1028
1138
  export type PreferenceResult = {
1029
1139
  status: "applied";
1030
1140
  } | {
@@ -1099,6 +1209,20 @@ export type ProviderEvent<C extends Channel = Channel> = {
1099
1209
  type: "task-ended";
1100
1210
  taskId: TaskId;
1101
1211
  outcome: TaskOutcome;
1212
+ }
1213
+ /**
1214
+ * How a dial the host placed ended, once, either way -- `answered` is stated, never read off
1215
+ * somebody appearing on the call. `taskId` where the dial was on a task, and that task may
1216
+ * already have ended: a dial placed late routinely outlives its call. `reason` is the switch's
1217
+ * own words, shown to the agent as such.
1218
+ */
1219
+ | {
1220
+ type: "dial-outcome";
1221
+ dialId: DialId;
1222
+ outcome: DialOutcome;
1223
+ taskId?: TaskId;
1224
+ destination?: string;
1225
+ reason?: string;
1102
1226
  } | {
1103
1227
  type: "announcement";
1104
1228
  text: string;
@@ -1168,6 +1292,8 @@ export interface Connection<C extends Channel = Channel> {
1168
1292
  openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
1169
1293
  /** Required when the login declares `capabilities.preferences`: the person's own choice, kept by the provider and republished as `authenticated`. */
1170
1294
  setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
1295
+ /** Records a handling leg the host performed. Required of a connection whose tasks may declare `mute`. */
1296
+ recordStep?(report: HandlingReport): Promise<HandlingReportResult>;
1171
1297
  }
1172
1298
  export interface Adapter<C extends Channel = Channel> {
1173
1299
  manifest: Manifest<C>;
@@ -1240,6 +1366,9 @@ export declare const taskKey: (providerId: string, taskId: TaskId) => string;
1240
1366
  export declare const userKey: (providerId: string, userId: UserId) => string;
1241
1367
  /** Every handling step somebody takes part in. `queued` is the one nobody does. */
1242
1368
  export declare const HANDLING_STEPS_WITH_A_PERSON: readonly ["offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered"];
1369
+ /** The steps a dial writes, and so the only ones that carry a `dialId` and a `destination`. */
1370
+ export declare const HANDLING_STEPS_THAT_DIAL: readonly ["transferred", "conferenced", "unanswered"];
1371
+ export declare const handlingStepDials: (step: HandlingStep) => boolean;
1243
1372
  /** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
1244
1373
  export declare function handlingStepExpectsAPerson(step: HandlingStep): boolean;
1245
1374
  export interface BrowserSessionKeyInput {
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ export function negotiateProtocolVersion(adapterVersions, hostVersions = OMNI_SU
15
15
  const shared = adapterVersions.filter(version => host.has(version));
16
16
  return shared.length === 0 ? undefined : Math.max(...shared);
17
17
  }
18
+ export const DIAL_OUTCOMES = ["answered", "busy", "no-answer", "unreachable", "rejected"];
18
19
  /** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
19
20
  export const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"];
20
21
  /** What Omni calls each idle capability. */
@@ -50,6 +51,7 @@ export function isAllowedBrowserUrl(url) {
50
51
  return false;
51
52
  }
52
53
  }
54
+ export const ON_CALL_ROLES = ["party", "agent", "consulted", "conferenced"];
53
55
  /**
54
56
  * The levels a typical organisation has: the ladder in force when a manifest declares no
55
57
  * `orgLevels`. A manifest that declares any states its whole ladder outright.
@@ -73,6 +75,16 @@ export const TASK_COMMAND_NAMES = {
73
75
  chat: ["accept", "reject", "pause", "resume", "complete"],
74
76
  email: ["accept", "reject", "complete"],
75
77
  };
78
+ /** The dial a command places, by the host's identity for it; `undefined` for a command that dials nothing. */
79
+ export function commandDialId(command) {
80
+ if (command.type === "callback")
81
+ return command.dialId;
82
+ if (command.type === "transfer" && (command.action === undefined || command.action === "consult"))
83
+ return command.dialId;
84
+ if (command.type === "conference" && command.action === "add")
85
+ return command.dialId;
86
+ return undefined;
87
+ }
76
88
  export const BREAK_KINDS = [
77
89
  "short-break", "meal", "rest", "training", "coaching",
78
90
  "meeting", "administrative", "technical", "personal", "other",
@@ -138,6 +150,12 @@ export const userKey = (providerId, userId) => `${encodeURIComponent(providerId)
138
150
  export const HANDLING_STEPS_WITH_A_PERSON = [
139
151
  "offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered",
140
152
  ];
153
+ /** The steps a dial writes, and so the only ones that carry a `dialId` and a `destination`. */
154
+ export const HANDLING_STEPS_THAT_DIAL = ["transferred", "conferenced", "unanswered"];
155
+ export const handlingStepDials = (step) => HANDLING_STEPS_THAT_DIAL.includes(step);
156
+ // Pinned both ways: a step added to `HandlingStep` has to be placed here, and a step listed here
157
+ // has to exist there. `satisfies` on the list covers the second; this statement covers the first.
158
+ true;
141
159
  /** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
142
160
  export function handlingStepExpectsAPerson(step) {
143
161
  return HANDLING_STEPS_WITH_A_PERSON.includes(step);
package/dist/testing.d.ts CHANGED
@@ -7,7 +7,7 @@ export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation }
7
7
  * rules -- each optional part of a task, each optional part of the break state and roster, each
8
8
  * declared contribution, and each event type.
9
9
  */
10
- declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.consultation", "task.lead", "task.assisting", "task.media", "task.acceptance", "task.dispositions", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities", "team.policies"];
10
+ declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.onCall", "task.lead", "task.assisting", "task.media", "task.acceptance", "task.dispositions", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities", "team.policies"];
11
11
  export type ContractSubject = (typeof STATE_SUBJECTS)[number] | `event.${ProviderEvent["type"]}`;
12
12
  export interface AdapterContractResult {
13
13
  events: ProviderEventEnvelope[];
@@ -98,6 +98,10 @@ export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApp
98
98
  /** What a stream has said about the tasks it carries, and the rules across events. */
99
99
  export declare class TaskStream {
100
100
  private readonly tasks;
101
+ private readonly dials;
102
+ /** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
103
+ dialled(dialId: string): void;
104
+ private noteDials;
101
105
  private static stated;
102
106
  /** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
103
107
  seed(snapshot: unknown): void;
@@ -127,7 +131,7 @@ export declare function assertBreakFollowsItsRequests(envelopes: readonly Provid
127
131
  export declare function assertMediaFollowsTheTask(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
128
132
  /** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
129
133
  export declare function stillHost(report?: HostReport, guarantees?: HostGuarantees): Host;
130
- /** One provider as the host sees it when freezing a break attempt's participant set. */
134
+ /** One provider as the host sees it when freezing the providers a break attempt asks. */
131
135
  export interface BreakCandidate {
132
136
  id: string;
133
137
  authentication: AuthenticationState["status"];
@@ -135,13 +139,13 @@ export interface BreakCandidate {
135
139
  holdsCapacity: boolean;
136
140
  }
137
141
  /**
138
- * The participant set of a break attempt is every connected provider from which the agent can
139
- * currently receive work. A provider whose login is not usable -- `expired` above all -- is not
140
- * one, whatever else is true of it: nothing can be asked of it, and a host that waits on it
141
- * stalls the break for everyone. A usable provider holding capacity is one, and cannot be left
142
- * out. `refreshing` is usable: identity and capabilities remain available and work continues.
142
+ * A break attempt asks every connected provider from which the agent can currently receive work.
143
+ * A provider whose login is not usable -- `expired` above all -- is not asked, whatever else is
144
+ * true of it: nothing can be asked of it, and a host that waits on it stalls the break for
145
+ * everyone. A usable provider holding capacity is asked, and cannot be left out. `refreshing` is
146
+ * usable: identity and capabilities remain available and work continues.
143
147
  */
144
- export declare function assertBreakParticipants(candidates: readonly BreakCandidate[], participants: readonly string[]): void;
148
+ export declare function assertBreakAttemptProviders(candidates: readonly BreakCandidate[], asked: readonly string[]): void;
145
149
  /** One published moment of a break asked for on a task: the approval, and how many tasks were outstanding. */
146
150
  export interface BreakOnTaskStep {
147
151
  approval: BreakApproval;
package/dist/testing.js CHANGED
@@ -12,7 +12,7 @@ const STATE_SUBJECTS = [
12
12
  "task.browsers",
13
13
  "task.attributes",
14
14
  "task.handlingHistory",
15
- "task.consultation",
15
+ "task.onCall",
16
16
  "task.lead",
17
17
  "task.assisting",
18
18
  "task.media",
@@ -33,7 +33,7 @@ const STATE_SUBJECTS = [
33
33
  // `ProviderEvent` without a row here, or a row it lacks, is a compile error.
34
34
  const EVENT_TYPES = {
35
35
  snapshot: true, "transport-status": true, "break-state": true, "task-offered": true, "task-updated": true,
36
- "task-media-started": true, "task-media-ended": true, "task-ended": true, announcement: true, "queue-summary": true, diagnostic: true,
36
+ "task-media-started": true, "task-media-ended": true, "task-ended": true, "dial-outcome": true, announcement: true, "queue-summary": true, diagnostic: true,
37
37
  "team-updated": true, "contacts-updated": true, "calendar-updated": true,
38
38
  };
39
39
  const CONTRACT_SUBJECTS = [
@@ -52,10 +52,10 @@ function observeTask(value, seen) {
52
52
  seen.add("task.browsers");
53
53
  if (some(value.attributes))
54
54
  seen.add("task.attributes");
55
- if (some(value.handlingHistory))
55
+ if (isRecord(value.handlingHistory) && some(value.handlingHistory.steps))
56
56
  seen.add("task.handlingHistory");
57
- if (value.consultation !== undefined)
58
- seen.add("task.consultation");
57
+ if (some(value.onCall))
58
+ seen.add("task.onCall");
59
59
  if (value.lead !== undefined)
60
60
  seen.add("task.lead");
61
61
  if (value.assisting !== undefined)
@@ -289,6 +289,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
289
289
  }
290
290
  if (eventNamesUsers(envelope))
291
291
  requireMethod(live, "describeUsers", "an event publishes a UserId");
292
+ if (eventDeclaresMute(envelope))
293
+ requireMethod(live, "recordStep", "a task declares mute, which the host performs and must have somewhere to record");
292
294
  // Cross-event rules apply once the stream has a beginning: the connect snapshot.
293
295
  if (seeded)
294
296
  violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
@@ -308,6 +310,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
308
310
  requireCapabilityMethods(live, current().capabilities);
309
311
  if (publishesUserIds(snapshot))
310
312
  requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
313
+ if (isRecord(snapshot) && Array.isArray(snapshot.tasks) && snapshot.tasks.some(taskDeclaresMute)) {
314
+ requireMethod(live, "recordStep", "a task declares mute, which the host performs and must have somewhere to record");
315
+ }
311
316
  // Capacity is stated, not requested: nothing may be allocated until it is, so a connection
312
317
  // that will not accept one is a connection nothing can be given to.
313
318
  // The guide's obligation on a voice adapter: consult the host before declaring the agent
@@ -398,9 +403,21 @@ function publishesUserIds(snapshot) {
398
403
  return snapshot.tasks.some(taskNamesUsers);
399
404
  }
400
405
  const teamNamesUsers = (team) => isRecord(team) && (some(team.members) || some(team.requests));
401
- const taskNamesUsers = (task) => isRecord(task) && ((Array.isArray(task.handlingHistory) && task.handlingHistory.some(step => isRecord(step) && step.by !== undefined)) ||
406
+ const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.handlingHistory) && Array.isArray(task.handlingHistory.steps) && task.handlingHistory.steps.some(step => isRecord(step) && step.by !== undefined)) ||
402
407
  (isRecord(task.lead) && task.lead.leadId !== undefined) ||
403
408
  isRecord(task.assisting));
409
+ /** Mute is the leg the host performs, so a task that allows it obliges the provider to take the record. */
410
+ const taskDeclaresMute = (task) => isRecord(task) && isRecord(task.capabilities) && task.capabilities.mute !== undefined;
411
+ function eventDeclaresMute(envelope) {
412
+ const event = isRecord(envelope) ? envelope.event : undefined;
413
+ if (!isRecord(event))
414
+ return false;
415
+ if (event.type === "task-offered" || event.type === "task-updated")
416
+ return taskDeclaresMute(event.task);
417
+ if (event.type === "snapshot" && isRecord(event.snapshot) && Array.isArray(event.snapshot.tasks))
418
+ return event.snapshot.tasks.some(taskDeclaresMute);
419
+ return false;
420
+ }
404
421
  /** Whether an event publishes a `UserId`, on a roster, a task, or the snapshot a reconnect carries. */
405
422
  function eventNamesUsers(envelope) {
406
423
  const event = isRecord(envelope) ? envelope.event : undefined;
@@ -600,6 +617,27 @@ const WORK_BEGUN = new Set(["in-progress", "paused", "completing"]);
600
617
  /** What a stream has said about the tasks it carries, and the rules across events. */
601
618
  export class TaskStream {
602
619
  tasks = new Map();
620
+ // Every dial the stream can place an outcome against: one the host said it placed, or one a
621
+ // task carried on `onCall` or in its record -- which is how a dial made before a transfer is known
622
+ // to whoever holds the task now. `ended` once its outcome arrived, since an outcome comes once.
623
+ dials = new Map();
624
+ /** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
625
+ dialled(dialId) {
626
+ if (!this.dials.has(dialId))
627
+ this.dials.set(dialId, "placed");
628
+ }
629
+ noteDials(task) {
630
+ if (!isRecord(task))
631
+ return;
632
+ const entries = [
633
+ ...(Array.isArray(task.onCall) ? task.onCall : []),
634
+ ...(isRecord(task.handlingHistory) && Array.isArray(task.handlingHistory.steps) ? task.handlingHistory.steps : []),
635
+ ];
636
+ for (const entry of entries) {
637
+ if (isRecord(entry) && typeof entry.dialId === "string")
638
+ this.dialled(entry.dialId);
639
+ }
640
+ }
603
641
  static stated(task) {
604
642
  const media = isRecord(task) && (task.media === "started" || task.media === "ended") ? task.media : "none";
605
643
  return { phase: String(isRecord(task) ? task.phase : undefined), media };
@@ -612,6 +650,7 @@ export class TaskStream {
612
650
  for (const task of snapshot.tasks) {
613
651
  if (isRecord(task) && typeof task.id === "string")
614
652
  this.tasks.set(task.id, TaskStream.stated(task));
653
+ this.noteDials(task);
615
654
  }
616
655
  }
617
656
  /** Applies one envelope and returns what it may not say given what came before. */
@@ -634,6 +673,7 @@ export class TaskStream {
634
673
  if (known !== undefined)
635
674
  refuse("stream.taskOffered.duplicate", `${at}.task.id`, `${id} is already on the stream; an offer introduces a task once`);
636
675
  this.tasks.set(id, TaskStream.stated(event.task));
676
+ this.noteDials(event.task);
637
677
  break;
638
678
  case "task-updated":
639
679
  if (id === undefined)
@@ -658,6 +698,7 @@ export class TaskStream {
658
698
  }
659
699
  this.tasks.set(id, next);
660
700
  }
701
+ this.noteDials(event.task);
661
702
  break;
662
703
  case "task-media-started":
663
704
  if (id === undefined)
@@ -696,6 +737,21 @@ export class TaskStream {
696
737
  refuse("stream.taskEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
697
738
  this.tasks.delete(id);
698
739
  break;
740
+ case "dial-outcome": {
741
+ // An outcome ends a dial somebody placed, once. The task it names may already have ended;
742
+ // a dial placed late routinely outlives its call, which is why the dial has its own identity.
743
+ if (typeof event.dialId !== "string")
744
+ break;
745
+ const dial = this.dials.get(event.dialId);
746
+ if (dial === undefined) {
747
+ refuse("stream.dialOutcome.unknown", `${at}.dialId`, `${event.dialId} is not a dial the host placed or a task carried: an outcome ends a dial somebody made`);
748
+ }
749
+ else if (dial === "ended") {
750
+ refuse("stream.dialOutcome.duplicate", `${at}.dialId`, `${event.dialId} already has its outcome; a dial ends once`);
751
+ }
752
+ this.dials.set(event.dialId, "ended");
753
+ break;
754
+ }
699
755
  default:
700
756
  break;
701
757
  }
@@ -785,26 +841,26 @@ export function stillHost(report = { online: true }, guarantees = {}) {
785
841
  }
786
842
  const usableLogin = (status) => status === "authenticated" || status === "refreshing";
787
843
  /**
788
- * The participant set of a break attempt is every connected provider from which the agent can
789
- * currently receive work. A provider whose login is not usable -- `expired` above all -- is not
790
- * one, whatever else is true of it: nothing can be asked of it, and a host that waits on it
791
- * stalls the break for everyone. A usable provider holding capacity is one, and cannot be left
792
- * out. `refreshing` is usable: identity and capabilities remain available and work continues.
844
+ * A break attempt asks every connected provider from which the agent can currently receive work.
845
+ * A provider whose login is not usable -- `expired` above all -- is not asked, whatever else is
846
+ * true of it: nothing can be asked of it, and a host that waits on it stalls the break for
847
+ * everyone. A usable provider holding capacity is asked, and cannot be left out. `refreshing` is
848
+ * usable: identity and capabilities remain available and work continues.
793
849
  */
794
- export function assertBreakParticipants(candidates, participants) {
795
- const chosen = new Set(participants);
796
- for (const id of participants) {
850
+ export function assertBreakAttemptProviders(candidates, asked) {
851
+ const chosen = new Set(asked);
852
+ for (const id of asked) {
797
853
  if (!candidates.some(candidate => candidate.id === id))
798
854
  throw new Error(`${id} is not a provider the host knows`);
799
855
  }
800
856
  for (const candidate of candidates) {
801
857
  const expected = usableLogin(candidate.authentication) && candidate.holdsCapacity;
802
858
  if (expected && !chosen.has(candidate.id)) {
803
- throw new Error(`${candidate.id} can give the agent work and must be a participant`);
859
+ throw new Error(`${candidate.id} can give the agent work and must be asked`);
804
860
  }
805
861
  if (!expected && chosen.has(candidate.id)) {
806
862
  const why = usableLogin(candidate.authentication) ? "holds no capacity" : `is ${candidate.authentication}`;
807
- throw new Error(`${candidate.id} ${why} and is not a participant: nothing can be asked of it`);
863
+ throw new Error(`${candidate.id} ${why} and is not asked: nothing can be asked of it`);
808
864
  }
809
865
  }
810
866
  }
@@ -17,6 +17,8 @@ export interface TaskValidationContext {
17
17
  levels?: readonly string[];
18
18
  /** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether a pending task states its `acceptance`. */
19
19
  autoAcceptTasks?: boolean;
20
+ /** Whether the manifest declares `dialOutcomes`. A task that may dial needs it to; absent, the question is not asked. */
21
+ dialOutcomesDeclared?: boolean;
20
22
  }
21
23
  export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
22
24
  /**
@@ -49,16 +51,22 @@ export declare function validateEventEnvelope(envelope: unknown, manifest: unkno
49
51
  * promise withheld is an absent key, never a false one, exactly as a capability is.
50
52
  */
51
53
  export declare function validateHostGuarantees(guarantees: unknown, path?: string): ProtocolViolation[];
54
+ /**
55
+ * What the host reports of a leg it performed, as an adapter may validate it before forwarding:
56
+ * a task, a step, when it began, how long so far if the host says, and an explicit end that
57
+ * carries the final duration.
58
+ */
59
+ export declare function validateHandlingReport(report: unknown, path?: string, manifest?: unknown): ProtocolViolation[];
52
60
  export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
53
61
  /** The connection methods whose results `validateResult` knows. */
54
- export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia" | "setPreference" | "executeTeamPolicy";
62
+ export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia" | "setPreference" | "recordStep" | "executeTeamPolicy";
55
63
  /**
56
64
  * Validates what a connection method answered. A result is untrusted for the same reason a
57
65
  * snapshot is: it comes from an adapter that may be compiled against another version, and Omni
58
66
  * shows the agent what it says. A status the method does not answer, a failure status without a
59
67
  * failure, a success carrying one, or an `omni.` code the contract lacks are each refused.
60
68
  */
61
- export declare function validateResult(result: unknown, method: ResultMethod, path?: string): ProtocolViolation[];
69
+ export declare function validateResult(result: unknown, method: ResultMethod, path?: string, dialId?: string): ProtocolViolation[];
62
70
  /** What a login is validated against beyond its own shape. */
63
71
  export interface LoginValidationContext {
64
72
  /** The level ids in force, from the manifest. The defaults when absent. */