@xema/omni-protocol 0.1.23 → 0.1.25

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
@@ -326,6 +326,12 @@ export interface ConnectContext {
326
326
  log?: (entry: unknown) => void;
327
327
  }
328
328
  export type ConnectionStatus = "connecting" | "active" | "error";
329
+ /**
330
+ * What revives a connection that reported `error`: `reconnect` -- the login is good, dispose this
331
+ * connection and call `connect()` again -- or `reauthenticate` -- run the authentication flow
332
+ * first. The adapter knows which; the host acts on its word.
333
+ */
334
+ export type ConnectionRecovery = "reconnect" | "reauthenticate";
329
335
  /** Every field is optional: a provider sends what it knows and omits what it does not. */
330
336
  export interface Contact {
331
337
  name?: string;
@@ -590,10 +596,12 @@ export type Task<C extends Channel = Channel> = {
590
596
  consultation?: TaskConsultation;
591
597
  lead?: TaskLead;
592
598
  assisting?: TaskAssisting;
599
+ media?: TaskMediaState;
593
600
  } : {
594
601
  consultation?: never;
595
602
  lead?: never;
596
603
  assisting?: never;
604
+ media?: never;
597
605
  });
598
606
  /** What the provider wants of Omni's acceptance policy for one offer. */
599
607
  export type AcceptanceMode = "no-preference" | "require-agent-acceptance" | "require-automatic-acceptance";
@@ -924,6 +932,12 @@ export type TeamCommandResult = {
924
932
  export interface TeamBreakCommandRequest {
925
933
  command: TeamBreakCommand;
926
934
  }
935
+ /**
936
+ * The task's real-time audio as the provider holds it: `ready` while audio should be attached,
937
+ * `ended` once primary handling's audio ended, and the field omitted while none should be. The
938
+ * provider's word -- a desk attaches and renders audio from it, never from its own senses.
939
+ */
940
+ export type TaskMediaState = "ready" | "ended";
927
941
  export interface VoiceMediaSession {
928
942
  remoteAudio: MediaStream;
929
943
  setMuted(muted: boolean): void;
@@ -992,6 +1006,8 @@ export interface Snapshot<C extends Channel = Channel> {
992
1006
  break: BreakState;
993
1007
  /** Every task currently owned by this agent for this provider. */
994
1008
  tasks: Task<C>[];
1009
+ /** The provider's own count of those tasks, stated rather than inferred: it must equal `tasks.length`, so a blank or unanswered state can never pass as a confirmed empty. */
1010
+ taskCount: number;
995
1011
  contacts?: Contact[];
996
1012
  scheduledActivities?: ScheduledActivity[];
997
1013
  team?: TeamRoster;
@@ -1024,7 +1040,12 @@ export type ProviderEvent<C extends Channel = Channel> = {
1024
1040
  snapshot: Snapshot<C>;
1025
1041
  } | {
1026
1042
  type: "provider-status";
1027
- status: ConnectionStatus;
1043
+ status: "connecting" | "active";
1044
+ message?: string;
1045
+ } | {
1046
+ type: "provider-status";
1047
+ status: "error";
1048
+ recovery: ConnectionRecovery;
1028
1049
  message?: string;
1029
1050
  } | {
1030
1051
  type: "break-state";
@@ -1038,6 +1059,9 @@ export type ProviderEvent<C extends Channel = Channel> = {
1038
1059
  } | {
1039
1060
  type: "task-updated";
1040
1061
  task: Task<C>;
1062
+ } | {
1063
+ type: "task-media-ready";
1064
+ taskId: TaskId;
1041
1065
  } | {
1042
1066
  type: "task-media-ended";
1043
1067
  taskId: TaskId;
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.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.consultation", "task.lead", "task.assisting", "task.media", "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,7 @@ 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 static stated;
101
102
  /** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
102
103
  seed(snapshot: unknown): void;
103
104
  /** Applies one envelope and returns what it may not say given what came before. */
@@ -119,8 +120,9 @@ export declare class BreakStream {
119
120
  export declare function assertBreakFollowsItsRequests(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
120
121
  /**
121
122
  * The media follows the task and never decides it. Given a provider's stream -- optionally seeded
122
- * with the snapshot it began from -- every task is introduced once, `task-media-ended` names a
123
- * task whose work has begun, and what follows it is `completing` or `task-ended`.
123
+ * with the snapshot it began from -- every task is introduced once, `task-media-ready` and
124
+ * `task-media-ended` alternate on work that has begun, media ends only where it arrived, and what
125
+ * follows the media ending is `completing` or `task-ended`.
124
126
  */
125
127
  export declare function assertMediaFollowsTheTask(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
126
128
  /** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
package/dist/testing.js CHANGED
@@ -15,6 +15,7 @@ const STATE_SUBJECTS = [
15
15
  "task.consultation",
16
16
  "task.lead",
17
17
  "task.assisting",
18
+ "task.media",
18
19
  "task.dispositions",
19
20
  "task.destinations",
20
21
  "task.custom",
@@ -31,7 +32,7 @@ const STATE_SUBJECTS = [
31
32
  // `ProviderEvent` without a row here, or a row it lacks, is a compile error.
32
33
  const EVENT_TYPES = {
33
34
  snapshot: true, "provider-status": true, "break-state": true, "task-offered": true, "task-updated": true,
34
- "task-media-ended": true, "task-ended": true, announcement: true, "provider-summary": true,
35
+ "task-media-ready": true, "task-media-ended": true, "task-ended": true, announcement: true, "provider-summary": true,
35
36
  "team-updated": true, "contacts-updated": true, "calendar-updated": true,
36
37
  };
37
38
  const CONTRACT_SUBJECTS = [
@@ -58,6 +59,8 @@ function observeTask(value, seen) {
58
59
  seen.add("task.lead");
59
60
  if (value.assisting !== undefined)
60
61
  seen.add("task.assisting");
62
+ if (value.media !== undefined)
63
+ seen.add("task.media");
61
64
  const capabilities = isRecord(value.capabilities) ? value.capabilities : {};
62
65
  if (isRecord(capabilities.dispositions))
63
66
  seen.add("task.dispositions");
@@ -578,13 +581,18 @@ export function assertDeniedAndRetriedBreak(approvals) {
578
581
  // ---------------------------------------------------------------------------
579
582
  // The task stream. Each event is validated on its own; what one event may say about a task
580
583
  // depends on what was said before, and only something that watched the whole stream can hold a
581
- // provider to it. A task is never its audio: media ends only on work that has begun, and what
582
- // follows the media ending is the work completing or ending, never a phase the audio decided.
584
+ // provider to it. A task is never its audio: media arrives on the provider's word, ends only
585
+ // where it arrived and on work that has begun, and what follows the media ending is the work
586
+ // completing or ending, never a phase the audio decided.
583
587
  // ---------------------------------------------------------------------------
584
588
  const WORK_BEGUN = new Set(["in-progress", "paused", "completing"]);
585
589
  /** What a stream has said about the tasks it carries, and the rules across events. */
586
590
  export class TaskStream {
587
591
  tasks = new Map();
592
+ static stated(task) {
593
+ const media = isRecord(task) && (task.media === "ready" || task.media === "ended") ? task.media : "none";
594
+ return { phase: String(isRecord(task) ? task.phase : undefined), media };
595
+ }
588
596
  /** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
589
597
  seed(snapshot) {
590
598
  this.tasks.clear();
@@ -592,7 +600,7 @@ export class TaskStream {
592
600
  return;
593
601
  for (const task of snapshot.tasks) {
594
602
  if (isRecord(task) && typeof task.id === "string")
595
- this.tasks.set(task.id, { phase: String(task.phase), mediaEnded: false });
603
+ this.tasks.set(task.id, TaskStream.stated(task));
596
604
  }
597
605
  }
598
606
  /** Applies one envelope and returns what it may not say given what came before. */
@@ -614,7 +622,7 @@ export class TaskStream {
614
622
  break;
615
623
  if (known !== undefined)
616
624
  refuse("stream.taskOffered.duplicate", `${at}.task.id`, `${id} is already on the stream; an offer introduces a task once`);
617
- this.tasks.set(id, { phase: String(isRecord(event.task) ? event.task.phase : undefined), mediaEnded: false });
625
+ this.tasks.set(id, TaskStream.stated(event.task));
618
626
  break;
619
627
  case "task-updated":
620
628
  if (id === undefined)
@@ -623,14 +631,37 @@ export class TaskStream {
623
631
  refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
624
632
  break;
625
633
  }
626
- if (known.mediaEnded) {
634
+ if (known.media === "ended") {
627
635
  const phase = isRecord(event.task) ? String(event.task.phase) : "";
628
636
  if (phase !== "completing") {
629
637
  refuse("stream.taskMediaEnded.follow", `${at}.task.phase`, `after its media ended, ${id} completes or ends; ${phase} is a phase the audio does not decide`);
630
638
  }
631
- known.mediaEnded = phase === "completing" ? false : known.mediaEnded;
632
639
  }
633
- known.phase = isRecord(event.task) ? String(event.task.phase) : known.phase;
640
+ // A task replaces the task, and an update re-states media without moving it: the
641
+ // transitions belong to task-media-ready and task-media-ended. Releasing ended is the
642
+ // one move an update may make, since wrapped audio has nothing left to end.
643
+ {
644
+ const next = TaskStream.stated(event.task);
645
+ if (known.media !== next.media && !(known.media === "ended" && next.media === "none")) {
646
+ refuse("stream.taskUpdated.media", `${at}.task.media`, `a task-updated re-states media, it does not move it: ${id} held ${known.media} and the update says ${next.media}; audio arrives on task-media-ready and ends on task-media-ended`);
647
+ }
648
+ this.tasks.set(id, next);
649
+ }
650
+ break;
651
+ case "task-media-ready":
652
+ if (id === undefined)
653
+ break;
654
+ if (known === undefined) {
655
+ refuse("stream.taskMediaReady.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
656
+ break;
657
+ }
658
+ if (!WORK_BEGUN.has(known.phase)) {
659
+ refuse("stream.taskMediaReady.beforeWork", `${at}.taskId`, `media cannot arrive on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
660
+ }
661
+ if (known.media === "ready") {
662
+ refuse("stream.taskMediaReady.duplicate", `${at}.taskId`, `media is already ready on ${id}; ready and ended alternate`);
663
+ }
664
+ known.media = "ready";
634
665
  break;
635
666
  case "task-media-ended":
636
667
  if (id === undefined)
@@ -642,7 +673,10 @@ export class TaskStream {
642
673
  if (!WORK_BEGUN.has(known.phase)) {
643
674
  refuse("stream.taskMediaEnded.beforeWork", `${at}.taskId`, `media cannot end on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
644
675
  }
645
- known.mediaEnded = true;
676
+ if (known.media !== "ready") {
677
+ refuse("stream.taskMediaEnded.silent", `${at}.taskId`, `media cannot end on ${id} where none arrived: audio attaches on task-media-ready, or on a task carried with media ready`);
678
+ }
679
+ known.media = "ended";
646
680
  break;
647
681
  case "task-ended":
648
682
  if (id === undefined)
@@ -722,8 +756,9 @@ export function assertBreakFollowsItsRequests(envelopes, snapshot) {
722
756
  }
723
757
  /**
724
758
  * The media follows the task and never decides it. Given a provider's stream -- optionally seeded
725
- * with the snapshot it began from -- every task is introduced once, `task-media-ended` names a
726
- * task whose work has begun, and what follows it is `completing` or `task-ended`.
759
+ * with the snapshot it began from -- every task is introduced once, `task-media-ready` and
760
+ * `task-media-ended` alternate on work that has begun, media ends only where it arrived, and what
761
+ * follows the media ending is `completing` or `task-ended`.
727
762
  */
728
763
  export function assertMediaFollowsTheTask(envelopes, snapshot) {
729
764
  const stream = new TaskStream();
@@ -36,6 +36,7 @@ export function assertNoViolations(violations, summary) {
36
36
  */
37
37
  const membersOf = (members) => Object.keys(members);
38
38
  const CHANNELS = membersOf({ voice: true, chat: true, email: true });
39
+ const CONNECTION_RECOVERIES = membersOf({ reconnect: true, reauthenticate: true });
39
40
  const TASK_PHASES = membersOf({
40
41
  pending: true, confirmed: true, preparing: true, "in-progress": true, paused: true, completing: true,
41
42
  });
@@ -648,6 +649,15 @@ function validateHandlingHistory(value, path, into) {
648
649
  });
649
650
  }
650
651
  /** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
652
+ const TASK_MEDIA_STATES = membersOf({ ready: true, ended: true });
653
+ /** Real-time media is a voice affair, and its state is one of two words. */
654
+ function validateTaskMedia(value, channel, path, into) {
655
+ if (value === undefined)
656
+ return;
657
+ if (!into.require(channel === "voice", "task.media.channel", path, `a ${channel} task carries no real-time media state`))
658
+ return;
659
+ into.oneOf(value, TASK_MEDIA_STATES, "task.media", path);
660
+ }
651
661
  function validateConsultation(value, channel, path, into) {
652
662
  if (value === undefined)
653
663
  return;
@@ -738,6 +748,7 @@ function validateTaskInto(task, context, path, into) {
738
748
  validateTaskAttributes(task.attributes, `${path}.attributes`, into);
739
749
  validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into);
740
750
  validateConsultation(task.consultation, context.channel, `${path}.consultation`, into);
751
+ validateTaskMedia(task.media, context.channel, `${path}.media`, into);
741
752
  validateLead(task.lead, context.channel, `${path}.lead`, into);
742
753
  validateAssisting(task.assisting, context.channel, `${path}.assisting`, into);
743
754
  const capabilities = task.capabilities;
@@ -982,6 +993,12 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
982
993
  into.require(snapshot.sessionId === context.sessionId, "snapshot.sessionId.mismatch", `${path}.sessionId`, `a snapshot for session ${String(snapshot.sessionId)} on a login whose session is ${context.sessionId}`);
983
994
  }
984
995
  validateBreakState(snapshot.break, `${path}.break`, into);
996
+ // The count is the provider's confirmation of how much work it answered with. Stated, never
997
+ // inferred: an unanswered or blank state lacks it, and cannot pass as a confirmed empty.
998
+ if (into.require(typeof snapshot.taskCount === "number" && Number.isInteger(snapshot.taskCount) && snapshot.taskCount >= 0, "snapshot.taskCount", `${path}.taskCount`, "a snapshot states its task count: a whole number, zero or more")
999
+ && Array.isArray(snapshot.tasks)) {
1000
+ into.require(snapshot.taskCount === snapshot.tasks.length, "snapshot.taskCount.mismatch", `${path}.taskCount`, `taskCount says ${snapshot.taskCount} and tasks carries ${snapshot.tasks.length}: a count that does not reconcile is an answer nobody gave`);
1001
+ }
985
1002
  if (!Array.isArray(snapshot.tasks)) {
986
1003
  into.add("snapshot.tasks.shape", `${path}.tasks`, "a snapshot must carry a tasks array");
987
1004
  }
@@ -1159,6 +1176,15 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1159
1176
  break;
1160
1177
  case "provider-status":
1161
1178
  into.oneOf(event.status, CONNECTION_STATUSES, "event.providerStatus.status", `${at}.status`);
1179
+ // An error says how to revive it; any other status has nothing to revive.
1180
+ if (event.status === "error") {
1181
+ if (into.require(event.recovery !== undefined, "event.providerStatus.recovery.required", `${at}.recovery`, "an error names its recovery: reconnect, or reauthenticate")) {
1182
+ into.oneOf(event.recovery, CONNECTION_RECOVERIES, "event.providerStatus.recovery", `${at}.recovery`);
1183
+ }
1184
+ }
1185
+ else {
1186
+ into.require(event.recovery === undefined, "event.providerStatus.recovery.unexpected", `${at}.recovery`, "recovery goes with an error; nothing needs reviving here");
1187
+ }
1162
1188
  if (event.message !== undefined) {
1163
1189
  into.filled(event.message, "event.providerStatus.message", `${at}.message`, "a message must not be empty when present");
1164
1190
  }
@@ -1190,6 +1216,9 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1190
1216
  case "task-updated":
1191
1217
  validateTaskInto(event.task, { channel, tiers }, `${at}.task`, into);
1192
1218
  break;
1219
+ case "task-media-ready":
1220
+ into.require(isTaskId(event.taskId), "event.taskMediaReady.taskId", `${at}.taskId`, "a task id is required");
1221
+ break;
1193
1222
  case "task-media-ended":
1194
1223
  into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
1195
1224
  break;
package/guide.md CHANGED
@@ -363,6 +363,7 @@ type Snapshot = {
363
363
  sessionId: string;
364
364
  break: BreakState;
365
365
  tasks: Task[];
366
+ taskCount: number;
366
367
  contacts?: Contact[];
367
368
  scheduledActivities?: ScheduledActivity[];
368
369
  team?: TeamRoster;
@@ -582,6 +583,8 @@ type Locked = {
582
583
 
583
584
  type Lockable<T> = T | Locked;
584
585
 
586
+ type TaskMediaState = "ready" | "ended";
587
+
585
588
  type Task<C extends Channel = Channel> = {
586
589
  id: TaskId;
587
590
  title: string;
@@ -596,8 +599,8 @@ type Task<C extends Channel = Channel> = {
596
599
  handlingHistory?: TaskHandlingStep[];
597
600
  } & TaskCompletion & (
598
601
  C extends "voice"
599
- ? { consultation?: TaskConsultation; lead?: TaskLead; assisting?: TaskAssisting }
600
- : { consultation?: never; lead?: never; assisting?: never }
602
+ ? { consultation?: TaskConsultation; lead?: TaskLead; assisting?: TaskAssisting; media?: TaskMediaState }
603
+ : { consultation?: never; lead?: never; assisting?: never; media?: never }
601
604
  );
602
605
 
603
606
  type AcceptanceMode =
@@ -919,9 +922,12 @@ type ProviderSummary = {
919
922
  metrics?: SummaryMetric[];
920
923
  };
921
924
 
925
+ type ConnectionRecovery = "reconnect" | "reauthenticate";
926
+
922
927
  type ProviderEvent =
923
928
  | { type: "snapshot"; reason: "reconnected" | "provider-requested"; snapshot: Snapshot }
924
- | { type: "provider-status"; status: ConnectionStatus; message?: string }
929
+ | { type: "provider-status"; status: "connecting" | "active"; message?: string }
930
+ | { type: "provider-status"; status: "error"; recovery: ConnectionRecovery; message?: string }
925
931
  | { type: "break-state"; break: BreakState }
926
932
  | {
927
933
  type: "task-offered";
@@ -931,6 +937,7 @@ type ProviderEvent =
931
937
  preparationEndsAt?: IsoTimestamp;
932
938
  }
933
939
  | { type: "task-updated"; task: Task }
940
+ | { type: "task-media-ready"; taskId: TaskId }
934
941
  | { type: "task-media-ended"; taskId: TaskId }
935
942
  | { type: "task-ended"; taskId: TaskId; outcome: TaskOutcome }
936
943
  | { type: "announcement"; text: string; html?: string; announcedAt: IsoTimestamp; expiresAt?: IsoTimestamp }
@@ -1217,6 +1224,17 @@ which field says so varies. See **Which commands need a capability**.
1217
1224
 
1218
1225
  Snapshots establish and replace provider state when an agent signs in, reconnects, or resynchronises.
1219
1226
 
1227
+ **A snapshot is the provider's answer, and an adapter that got no answer publishes nothing.** A
1228
+ state read that answers unknown — a session not yet associated, a backend mid-failover — is not an
1229
+ empty state: the adapter keeps what it holds, stays `connecting`, and publishes a snapshot only
1230
+ once the provider has actually answered for this login, exactly as `connect()` may not resolve
1231
+ before it can provide a meaningful one. And emptiness is stated, never inferred: every snapshot
1232
+ carries `taskCount`, the provider's own count reconciled against `tasks.length`, so a snapshot
1233
+ with no work says `taskCount: 0` in so many words and a blank or half-built state — which lacks
1234
+ the count — can never pass as a confirmed empty. Absence of knowledge is never evidence of
1235
+ absence, and every place "empty" is allowed to carry both meanings will eventually clear
1236
+ somebody's live call.
1237
+
1220
1238
  Events report completed transactions after that baseline. Nothing is missed while the connection
1221
1239
  holds; when it drops, the reconnect snapshot re-establishes the baseline before any further event
1222
1240
  is applied.
@@ -1793,6 +1811,9 @@ Creates one live provider connection for the signed-in agent.
1793
1811
  - May reject for authentication, configuration, or startup failure.
1794
1812
  - Must not create a second agent session merely because the underlying transport reconnects.
1795
1813
  - The returned connection owns reconnect until Omni calls `disconnect()` or aborts `context.signal`.
1814
+ - May be called again on the same login after that: once per `Connection`, not once per login.
1815
+ Omni disposes a connection whose `error` named `recovery: "reconnect"` with `disconnect()` and
1816
+ calls `connect()` afresh — see **`provider-status`**.
1796
1817
 
1797
1818
  ### `ConnectContext`
1798
1819
 
@@ -1828,6 +1849,7 @@ a capability it agrees with the login: a lead's snapshot carries `team`, nobody
1828
1849
  | `sessionId` | Identity of this login session. It must match the connection context. |
1829
1850
  | `break` | Complete break state, including approval, accepting state, reasons, retry details, and any imposed break. |
1830
1851
  | `tasks` | Complete set of tasks currently offered to or owned by this agent. |
1852
+ | `taskCount` | The provider's own count of those tasks, stated rather than inferred, and it must equal `tasks.length`. A snapshot with no work says `taskCount: 0` in so many words — a blank or unanswered state lacks the count and cannot pass as a confirmed empty. |
1831
1853
  | `contacts` | Required complete contact contribution when the manifest declares `contacts`; `[]` clears it. Omitted only when it does not. |
1832
1854
  | `scheduledActivities` | Required complete calendar contribution when the manifest declares `calendar`; `[]` clears it. Omitted only when it does not. |
1833
1855
  | `team` | Required `TeamRoster` when the login declares `capabilities.team`, `[]` when nobody is in it. Forbidden otherwise — the login is the permission. |
@@ -2048,6 +2070,7 @@ time. Runtime conformance checks also require the task channel to match its prov
2048
2070
  | `browsers` | Named browser definitions for the task workspace: at least one when the task declares the `browsers` capability, empty when it does not. |
2049
2071
  | `contact` | Optional `Contact` for the person or entity on this task. Often a name and one address; a withheld caller ID may leave nothing to send at all. |
2050
2072
  | `phase` | Current canonical task phase: `pending`, `confirmed`, `preparing`, `in-progress`, `paused`, or `completing`. |
2073
+ | `media` | Voice only. The task's real-time audio as the provider holds it: `ready` while audio should be attached, `ended` once it ended, omitted while none should be. The provider's word — see **`task-media-ready`**. |
2051
2074
  | `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
2052
2075
  | `completionMode` | `agent-command` waits for the channel's `complete` command; `provider-automatic` completes without one. |
2053
2076
  | `completionAllowance` | Fixed time allowed to complete the task after primary handling ends. For real-time media, it begins after `task-media-ended`. Required under `provider-automatic`, where the provider acts on it. Optional under `agent-command`: omitted says the provider imposes no deadline, and Omni counts nothing down. |
@@ -2107,7 +2130,8 @@ command.
2107
2130
  routed to the agent and accepted as `acceptanceMode` dictates, and its presence and phase follow
2108
2131
  the provider's reports about the work — never the audio. Wherever audio moves — an offer, a hold, a
2109
2132
  consult, a conference leg joining or leaving, a transfer, a callback — the media follows
2110
- separately, attaching through `openMedia` and ending with `task-media-ended`. Omni does not ring,
2133
+ separately, arriving on `task-media-ready`, attaching through `openMedia` and ending with
2134
+ `task-media-ended`. Omni does not ring,
2111
2135
  bridge, or hold a line. How the phone rings, whether it rings at all, and where legs join and leave
2112
2136
  are the adapter's and the platform's, transient, and decide neither when a task exists nor what
2113
2137
  phase it is in.
@@ -2118,9 +2142,10 @@ allowance starts on it and the callback control appears on it — and Omni follo
2118
2142
  follows any other. What Omni never does is derive a task's state from its own media session: a
2119
2143
  stream that drops, a track that ends, a transport that disconnects, a microphone that fails, an
2120
2144
  endpoint re-registering change nothing about the task until the provider says so. Structurally:
2121
- `task-media-ended` names a task whose work has begun, what follows it is `completing` or
2122
- `task-ended`, and every task is introduced once `exerciseAdapter` holds the stream to that from
2123
- the connect snapshot on, and `assertMediaFollowsTheTask` holds any sequence.
2145
+ `task-media-ready` and `task-media-ended` alternate on a task whose work has begun, media ends
2146
+ only where it arrived, what follows the media ending is `completing` or `task-ended`, and every
2147
+ task is introduced once `exerciseAdapter` holds the stream to that from the connect snapshot on,
2148
+ and `assertMediaFollowsTheTask` holds any sequence.
2124
2149
 
2125
2150
  #### Completion timing
2126
2151
 
@@ -3233,6 +3258,11 @@ carries as `audio.input.localAudio`, and absent while that input is `unavailable
3233
3258
  bridges audio without a host-side input may ignore it; one that needs it and finds it absent
3234
3259
  answers `unavailable` with a failure Omni shows the agent.
3235
3260
 
3261
+ **When to ask is the provider's word, not Omni's guess.** Omni opens media on `task-media-ready`,
3262
+ and on a task arriving with `media: "ready"` on a snapshot; it closes on `task-media-ended` and
3263
+ when the task ends. Between those words, nothing Omni's own senses report — a stream that drops, a
3264
+ track that ends — moves the task or its audio.
3265
+
3236
3266
  **A task-scoped session does not oblige one call per task.** A platform holding a nailed-up
3237
3267
  leg for a whole shift may return the same session for every task and release the underlying
3238
3268
  path only when the connection closes. A platform placing a call per contact returns a new one
@@ -3385,7 +3415,10 @@ silently lose a message, so while the connection is up Omni has seen everything
3385
3415
  Loss has exactly one shape: the connection went away. The adapter reports `connecting` or `error`,
3386
3416
  reconnects, and emits a `snapshot` event carrying complete state. That snapshot is the repair —
3387
3417
  whatever was missed while the connection was down is in it, and Omni replaces its provider view
3388
- rather than reasoning about what it did not receive.
3418
+ rather than reasoning about what it did not receive. A repair is an answer like any other: a
3419
+ platform that has not yet answered for this login after a reconnect — a state read served empty by
3420
+ a backend that does not know the session yet — yields no snapshot, and the adapter stays
3421
+ `connecting` holding what it holds. See **Snapshots establish state; events report transactions**.
3389
3422
 
3390
3423
  A snapshot must account for **everything the adapter has emitted before it resolves**, not merely
3391
3424
  everything emitted when it was requested. Omni discards events buffered during the read on that
@@ -3424,7 +3457,24 @@ safe for the agent to see.
3424
3457
  | --- | --- |
3425
3458
  | `connecting` | No usable transport right now, and the adapter expects to recover on its own. Nobody needs to act. Startup and every reconnect pass through this value. |
3426
3459
  | `active` | The transport is up and the provider is serving this session. It is the only value under which work arrives. |
3427
- | `error` | The adapter cannot serve the session and is not simply mid-reconnect. Say why in `message`. It is not terminal an adapter that recovers reports `connecting` and then `active`. |
3460
+ | `error` | The adapter cannot serve the session and is not simply mid-reconnect. Say why in `message`, and say what revives it in `recovery` — required here, forbidden on any other status. It is not terminal: an adapter that recovers on its own still reports `connecting` and then `active`, and one that cannot is revived as `recovery` says. |
3461
+
3462
+ **An error names its recovery, and the host acts on that word.** The adapter knows why its session
3463
+ died; the host knows how to run a login. `recovery` joins the two:
3464
+
3465
+ - **`reconnect`** — the login is good and this connection is not: a backend restart, a session the
3466
+ platform no longer recognises. Omni calls `disconnect()` on the dead connection and then
3467
+ `connect()` again on the same login — same `sessionId` — and the fresh connect snapshot
3468
+ re-establishes state exactly as a reconnect snapshot does. `connect()` is once per
3469
+ `Connection`, not once per login.
3470
+ - **`reauthenticate`** — the session under the login died: a token rejected, a remote logout. Omni
3471
+ runs the authentication flow first; the authentication session decides whether stored material
3472
+ refreshes it silently or the agent must act, exactly as at sign-in.
3473
+
3474
+ **Patience is the host's.** An adapter in `connecting` retries for as long as it takes and never
3475
+ has to decide when to stop. Omni owns giving up: after however long it chooses to wait, it may
3476
+ call `disconnect()` and either `connect()` afresh or surface the failure — so neither side waits
3477
+ for the other to blink.
3428
3478
 
3429
3479
  **Status is about the transport, nothing else.** It does not say whether the agent is available,
3430
3480
  whether they are on a break, or how much work they can take: capacity travels on `setCapacity`,
@@ -3474,10 +3524,27 @@ snapshots until it ends.
3474
3524
  Replaces the current representation of one provider-local task. It is a full task value, not a
3475
3525
  partial patch.
3476
3526
 
3527
+ ### `task-media-ready`
3528
+
3529
+ The provider's word that the task's audio should now attach. Omni calls `openMedia` on it — and on
3530
+ a task carried with `media: "ready"`, which is how a reconnect snapshot reattaches audio an
3531
+ earlier event brought — and renders the call as live from that word, never from its own senses. It
3532
+ names a task whose work has begun, and it alternates with `task-media-ended`: media that was never
3533
+ made ready cannot end, so a live call whose provider says nothing about its audio is a provider in
3534
+ breach, not a state a desk fills in from its own devices.
3535
+
3536
+ The event is the transition and the task's `media` field is the state. A `task-updated` re-states
3537
+ the media its task already holds — republishing `ready` on a hold is a statement, not a second
3538
+ arrival — but it does not move it: an update that itself flips the field is refused
3539
+ (`stream.taskUpdated.media`), and the pairing at the moment audio arrives is the phase change
3540
+ without the field, then the event. Releasing `ended` is the one move an update may make, since
3541
+ wrapped audio has nothing left to end.
3542
+
3477
3543
  ### `task-media-ended`
3478
3544
 
3479
3545
  Signals that a task's real-time media ended. For voice and similar channels, this starts the fixed
3480
- completion timer. It does not remove the task.
3546
+ completion timer. It does not remove the task, and it ends only audio that `task-media-ready` — or
3547
+ a task carried with `media: "ready"` — attached.
3481
3548
 
3482
3549
  ### `task-ended`
3483
3550
 
@@ -3566,7 +3633,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
3566
3633
  | --- | --- |
3567
3634
  | `validateManifest(manifest)` | Identity, protocol-version interoperability, authentication methods, and idle-capability shapes. |
3568
3635
  | `validateTask(task, { channel })` | Identity, channel agreement, phase, completion allowance, capability shapes, custom controls, and browsers. |
3569
- | `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team roster, and every task, contact, and activity, including idle-capability gating both ways: a contribution the manifest never declared is refused, and one it declares is required, `[]` included. |
3636
+ | `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team roster, the stated `taskCount` reconciled against the tasks carried, and every task, contact, and activity, including idle-capability gating both ways: a contribution the manifest never declared is refused, and one it declares is required, `[]` included. |
3570
3637
  | `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
3571
3638
  | `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
3572
3639
  | `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
@@ -3660,7 +3727,7 @@ cannot be established from TypeScript structure alone.
3660
3727
  | `stillHost(report?)` | A host that reports one thing and never changes, for a test context: `{ online: true }` by default, a report with audio for a voice adapter. |
3661
3728
  | `TaskStream`, `BreakStream` | The cross-event models the harness applies after the connect snapshot, exported for a host that wants the same rules at its boundary: `seed(snapshot)`, then `apply(envelope)` returns the violations. |
3662
3729
  | `assertBreakFollowsItsRequests(envelopes, snapshot?)` | A break follows its requests: a commit's states only after a grant, never backwards, and a placed break arriving in effect with `imposed`. The harness applies the same rules after the connect snapshot. |
3663
- | `assertMediaFollowsTheTask(envelopes, snapshot?)` | The media follows the task and never decides it: every task is introduced once, `task-media-ended` names a task whose work has begun, and what follows it is `completing` or `task-ended`. The harness applies the same rules to every event after the connect snapshot (`stream.*`). A sequence with no media satisfies it by never testing it — pair it with the assertion that the media end is present. |
3730
+ | `assertMediaFollowsTheTask(envelopes, snapshot?)` | The media follows the task and never decides it: every task is introduced once, `task-media-ready` and `task-media-ended` alternate on work that has begun, media ends only where it arrived, and what follows the media ending is `completing` or `task-ended`. The harness applies the same rules to every event after the connect snapshot (`stream.*`). A sequence with no media satisfies it by never testing it — pair it with the assertion that the media end is present. |
3664
3731
  | `assertBreakParticipants(candidates, participants)` | A break attempt asks every usable provider holding capacity, `refreshing` included, and nothing of a provider whose login is `expired`. |
3665
3732
  | `assertBreakBeginsAfterTask(steps)` | A break asked for on a task is committed as `starting-after-task` while work remains and reaches `in-effect` only once nothing is outstanding — never beside a task, never later than the step that has none. |
3666
3733
  | `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",