@xema/omni-protocol 0.1.23 → 0.1.24

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;
@@ -1024,7 +1038,12 @@ export type ProviderEvent<C extends Channel = Channel> = {
1024
1038
  snapshot: Snapshot<C>;
1025
1039
  } | {
1026
1040
  type: "provider-status";
1027
- status: ConnectionStatus;
1041
+ status: "connecting" | "active";
1042
+ message?: string;
1043
+ } | {
1044
+ type: "provider-status";
1045
+ status: "error";
1046
+ recovery: ConnectionRecovery;
1028
1047
  message?: string;
1029
1048
  } | {
1030
1049
  type: "break-state";
@@ -1038,6 +1057,9 @@ export type ProviderEvent<C extends Channel = Channel> = {
1038
1057
  } | {
1039
1058
  type: "task-updated";
1040
1059
  task: Task<C>;
1060
+ } | {
1061
+ type: "task-media-ready";
1062
+ taskId: TaskId;
1041
1063
  } | {
1042
1064
  type: "task-media-ended";
1043
1065
  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,29 @@ 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: the update's own media field is the state now stated.
641
+ this.tasks.set(id, TaskStream.stated(event.task));
642
+ break;
643
+ case "task-media-ready":
644
+ if (id === undefined)
645
+ break;
646
+ if (known === undefined) {
647
+ refuse("stream.taskMediaReady.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
648
+ break;
649
+ }
650
+ if (!WORK_BEGUN.has(known.phase)) {
651
+ 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`);
652
+ }
653
+ if (known.media === "ready") {
654
+ refuse("stream.taskMediaReady.duplicate", `${at}.taskId`, `media is already ready on ${id}; ready and ended alternate`);
655
+ }
656
+ known.media = "ready";
634
657
  break;
635
658
  case "task-media-ended":
636
659
  if (id === undefined)
@@ -642,7 +665,10 @@ export class TaskStream {
642
665
  if (!WORK_BEGUN.has(known.phase)) {
643
666
  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
667
  }
645
- known.mediaEnded = true;
668
+ if (known.media !== "ready") {
669
+ 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`);
670
+ }
671
+ known.media = "ended";
646
672
  break;
647
673
  case "task-ended":
648
674
  if (id === undefined)
@@ -722,8 +748,9 @@ export function assertBreakFollowsItsRequests(envelopes, snapshot) {
722
748
  }
723
749
  /**
724
750
  * 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`.
751
+ * with the snapshot it began from -- every task is introduced once, `task-media-ready` and
752
+ * `task-media-ended` alternate on work that has begun, media ends only where it arrived, and what
753
+ * follows the media ending is `completing` or `task-ended`.
727
754
  */
728
755
  export function assertMediaFollowsTheTask(envelopes, snapshot) {
729
756
  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;
@@ -1159,6 +1170,15 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1159
1170
  break;
1160
1171
  case "provider-status":
1161
1172
  into.oneOf(event.status, CONNECTION_STATUSES, "event.providerStatus.status", `${at}.status`);
1173
+ // An error says how to revive it; any other status has nothing to revive.
1174
+ if (event.status === "error") {
1175
+ if (into.require(event.recovery !== undefined, "event.providerStatus.recovery.required", `${at}.recovery`, "an error names its recovery: reconnect, or reauthenticate")) {
1176
+ into.oneOf(event.recovery, CONNECTION_RECOVERIES, "event.providerStatus.recovery", `${at}.recovery`);
1177
+ }
1178
+ }
1179
+ else {
1180
+ into.require(event.recovery === undefined, "event.providerStatus.recovery.unexpected", `${at}.recovery`, "recovery goes with an error; nothing needs reviving here");
1181
+ }
1162
1182
  if (event.message !== undefined) {
1163
1183
  into.filled(event.message, "event.providerStatus.message", `${at}.message`, "a message must not be empty when present");
1164
1184
  }
@@ -1190,6 +1210,9 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1190
1210
  case "task-updated":
1191
1211
  validateTaskInto(event.task, { channel, tiers }, `${at}.task`, into);
1192
1212
  break;
1213
+ case "task-media-ready":
1214
+ into.require(isTaskId(event.taskId), "event.taskMediaReady.taskId", `${at}.taskId`, "a task id is required");
1215
+ break;
1193
1216
  case "task-media-ended":
1194
1217
  into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
1195
1218
  break;
package/guide.md CHANGED
@@ -582,6 +582,8 @@ type Locked = {
582
582
 
583
583
  type Lockable<T> = T | Locked;
584
584
 
585
+ type TaskMediaState = "ready" | "ended";
586
+
585
587
  type Task<C extends Channel = Channel> = {
586
588
  id: TaskId;
587
589
  title: string;
@@ -596,8 +598,8 @@ type Task<C extends Channel = Channel> = {
596
598
  handlingHistory?: TaskHandlingStep[];
597
599
  } & TaskCompletion & (
598
600
  C extends "voice"
599
- ? { consultation?: TaskConsultation; lead?: TaskLead; assisting?: TaskAssisting }
600
- : { consultation?: never; lead?: never; assisting?: never }
601
+ ? { consultation?: TaskConsultation; lead?: TaskLead; assisting?: TaskAssisting; media?: TaskMediaState }
602
+ : { consultation?: never; lead?: never; assisting?: never; media?: never }
601
603
  );
602
604
 
603
605
  type AcceptanceMode =
@@ -919,9 +921,12 @@ type ProviderSummary = {
919
921
  metrics?: SummaryMetric[];
920
922
  };
921
923
 
924
+ type ConnectionRecovery = "reconnect" | "reauthenticate";
925
+
922
926
  type ProviderEvent =
923
927
  | { type: "snapshot"; reason: "reconnected" | "provider-requested"; snapshot: Snapshot }
924
- | { type: "provider-status"; status: ConnectionStatus; message?: string }
928
+ | { type: "provider-status"; status: "connecting" | "active"; message?: string }
929
+ | { type: "provider-status"; status: "error"; recovery: ConnectionRecovery; message?: string }
925
930
  | { type: "break-state"; break: BreakState }
926
931
  | {
927
932
  type: "task-offered";
@@ -931,6 +936,7 @@ type ProviderEvent =
931
936
  preparationEndsAt?: IsoTimestamp;
932
937
  }
933
938
  | { type: "task-updated"; task: Task }
939
+ | { type: "task-media-ready"; taskId: TaskId }
934
940
  | { type: "task-media-ended"; taskId: TaskId }
935
941
  | { type: "task-ended"; taskId: TaskId; outcome: TaskOutcome }
936
942
  | { type: "announcement"; text: string; html?: string; announcedAt: IsoTimestamp; expiresAt?: IsoTimestamp }
@@ -1793,6 +1799,9 @@ Creates one live provider connection for the signed-in agent.
1793
1799
  - May reject for authentication, configuration, or startup failure.
1794
1800
  - Must not create a second agent session merely because the underlying transport reconnects.
1795
1801
  - The returned connection owns reconnect until Omni calls `disconnect()` or aborts `context.signal`.
1802
+ - May be called again on the same login after that: once per `Connection`, not once per login.
1803
+ Omni disposes a connection whose `error` named `recovery: "reconnect"` with `disconnect()` and
1804
+ calls `connect()` afresh — see **`provider-status`**.
1796
1805
 
1797
1806
  ### `ConnectContext`
1798
1807
 
@@ -2048,6 +2057,7 @@ time. Runtime conformance checks also require the task channel to match its prov
2048
2057
  | `browsers` | Named browser definitions for the task workspace: at least one when the task declares the `browsers` capability, empty when it does not. |
2049
2058
  | `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
2059
  | `phase` | Current canonical task phase: `pending`, `confirmed`, `preparing`, `in-progress`, `paused`, or `completing`. |
2060
+ | `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
2061
  | `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
2052
2062
  | `completionMode` | `agent-command` waits for the channel's `complete` command; `provider-automatic` completes without one. |
2053
2063
  | `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 +2117,8 @@ command.
2107
2117
  routed to the agent and accepted as `acceptanceMode` dictates, and its presence and phase follow
2108
2118
  the provider's reports about the work — never the audio. Wherever audio moves — an offer, a hold, a
2109
2119
  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,
2120
+ separately, arriving on `task-media-ready`, attaching through `openMedia` and ending with
2121
+ `task-media-ended`. Omni does not ring,
2111
2122
  bridge, or hold a line. How the phone rings, whether it rings at all, and where legs join and leave
2112
2123
  are the adapter's and the platform's, transient, and decide neither when a task exists nor what
2113
2124
  phase it is in.
@@ -2118,9 +2129,10 @@ allowance starts on it and the callback control appears on it — and Omni follo
2118
2129
  follows any other. What Omni never does is derive a task's state from its own media session: a
2119
2130
  stream that drops, a track that ends, a transport that disconnects, a microphone that fails, an
2120
2131
  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.
2132
+ `task-media-ready` and `task-media-ended` alternate on a task whose work has begun, media ends
2133
+ only where it arrived, what follows the media ending is `completing` or `task-ended`, and every
2134
+ task is introduced once `exerciseAdapter` holds the stream to that from the connect snapshot on,
2135
+ and `assertMediaFollowsTheTask` holds any sequence.
2124
2136
 
2125
2137
  #### Completion timing
2126
2138
 
@@ -3233,6 +3245,11 @@ carries as `audio.input.localAudio`, and absent while that input is `unavailable
3233
3245
  bridges audio without a host-side input may ignore it; one that needs it and finds it absent
3234
3246
  answers `unavailable` with a failure Omni shows the agent.
3235
3247
 
3248
+ **When to ask is the provider's word, not Omni's guess.** Omni opens media on `task-media-ready`,
3249
+ and on a task arriving with `media: "ready"` on a snapshot; it closes on `task-media-ended` and
3250
+ when the task ends. Between those words, nothing Omni's own senses report — a stream that drops, a
3251
+ track that ends — moves the task or its audio.
3252
+
3236
3253
  **A task-scoped session does not oblige one call per task.** A platform holding a nailed-up
3237
3254
  leg for a whole shift may return the same session for every task and release the underlying
3238
3255
  path only when the connection closes. A platform placing a call per contact returns a new one
@@ -3424,7 +3441,24 @@ safe for the agent to see.
3424
3441
  | --- | --- |
3425
3442
  | `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
3443
  | `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`. |
3444
+ | `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. |
3445
+
3446
+ **An error names its recovery, and the host acts on that word.** The adapter knows why its session
3447
+ died; the host knows how to run a login. `recovery` joins the two:
3448
+
3449
+ - **`reconnect`** — the login is good and this connection is not: a backend restart, a session the
3450
+ platform no longer recognises. Omni calls `disconnect()` on the dead connection and then
3451
+ `connect()` again on the same login — same `sessionId` — and the fresh connect snapshot
3452
+ re-establishes state exactly as a reconnect snapshot does. `connect()` is once per
3453
+ `Connection`, not once per login.
3454
+ - **`reauthenticate`** — the session under the login died: a token rejected, a remote logout. Omni
3455
+ runs the authentication flow first; the authentication session decides whether stored material
3456
+ refreshes it silently or the agent must act, exactly as at sign-in.
3457
+
3458
+ **Patience is the host's.** An adapter in `connecting` retries for as long as it takes and never
3459
+ has to decide when to stop. Omni owns giving up: after however long it chooses to wait, it may
3460
+ call `disconnect()` and either `connect()` afresh or surface the failure — so neither side waits
3461
+ for the other to blink.
3428
3462
 
3429
3463
  **Status is about the transport, nothing else.** It does not say whether the agent is available,
3430
3464
  whether they are on a break, or how much work they can take: capacity travels on `setCapacity`,
@@ -3474,10 +3508,20 @@ snapshots until it ends.
3474
3508
  Replaces the current representation of one provider-local task. It is a full task value, not a
3475
3509
  partial patch.
3476
3510
 
3511
+ ### `task-media-ready`
3512
+
3513
+ The provider's word that the task's audio should now attach. Omni calls `openMedia` on it — and on
3514
+ a task carried with `media: "ready"`, which is how a reconnect snapshot reattaches audio an
3515
+ earlier event brought — and renders the call as live from that word, never from its own senses. It
3516
+ names a task whose work has begun, and it alternates with `task-media-ended`: media that was never
3517
+ made ready cannot end, so a live call whose provider says nothing about its audio is a provider in
3518
+ breach, not a state a desk fills in from its own devices.
3519
+
3477
3520
  ### `task-media-ended`
3478
3521
 
3479
3522
  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.
3523
+ completion timer. It does not remove the task, and it ends only audio that `task-media-ready` — or
3524
+ a task carried with `media: "ready"` — attached.
3481
3525
 
3482
3526
  ### `task-ended`
3483
3527
 
@@ -3660,7 +3704,7 @@ cannot be established from TypeScript structure alone.
3660
3704
  | `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
3705
  | `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
3706
  | `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. |
3707
+ | `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
3708
  | `assertBreakParticipants(candidates, participants)` | A break attempt asks every usable provider holding capacity, `refreshing` included, and nothing of a provider whose login is `expired`. |
3665
3709
  | `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
3710
  | `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.24",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",