@xema/omni-protocol 0.1.16 → 0.1.17

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
@@ -247,12 +247,32 @@ export interface AuthenticationSession {
247
247
  signOut(): Promise<AuthenticationActionResult>;
248
248
  close(): Promise<void>;
249
249
  }
250
+ /**
251
+ * How the host's own audio stands: the microphone Omni captures for the agent, and whether it has
252
+ * it. Omni facilitates the microphone -- captures it, prompts, retries, tells the agent -- and
253
+ * does not decide for the adapter what a missing one means. The adapter does what its platform
254
+ * needs: go not-ready, refuse calls, or carry on because audio lands elsewhere.
255
+ */
256
+ export type HostMediaState = {
257
+ status: "ready";
258
+ localAudio: MediaStream;
259
+ } | {
260
+ status: "unavailable";
261
+ failure: ProtocolFailure;
262
+ };
263
+ /** The host's audio, observable for the life of the connection, as authentication is. */
264
+ export interface HostMedia {
265
+ state(): HostMediaState;
266
+ subscribe(listener: (state: HostMediaState) => void): Unsubscribe;
267
+ }
250
268
  export interface ConnectContext {
251
269
  protocolVersion: number;
252
270
  /** The session that authenticated this connection. */
253
271
  sessionId: string;
254
272
  /** Omni-side policy: whether the agent's tasks are accepted without asking them. */
255
273
  autoAcceptTasks?: boolean;
274
+ /** The host's audio. Present on a voice connection; absent on a channel with no media. */
275
+ media?: HostMedia;
256
276
  signal?: AbortSignal;
257
277
  log?: (entry: unknown) => void;
258
278
  }
@@ -791,7 +811,8 @@ export interface VoiceMediaSession {
791
811
  }
792
812
  export interface OpenMediaRequest {
793
813
  taskId: TaskId;
794
- localAudio: MediaStream;
814
+ /** The agent's microphone as Omni captured it; absent while `HostMediaState` is `unavailable`. */
815
+ localAudio?: MediaStream;
795
816
  }
796
817
  export type OpenMediaResult = {
797
818
  status: "opened";
package/dist/testing.d.ts CHANGED
@@ -95,6 +95,20 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
95
95
  * both.
96
96
  */
97
97
  export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
98
+ /** What a stream has said about the tasks it carries, and the rules across events. */
99
+ export declare class TaskStream {
100
+ private readonly tasks;
101
+ /** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
102
+ seed(snapshot: unknown): void;
103
+ /** Applies one envelope and returns what it may not say given what came before. */
104
+ apply(envelope: unknown, path?: string): ProtocolViolation[];
105
+ }
106
+ /**
107
+ * The media follows the task and never decides it. Given a provider's stream -- optionally seeded
108
+ * with the snapshot it began from -- every task is introduced once, `task-media-ended` names a
109
+ * task whose work has begun, and what follows it is `completing` or `task-ended`.
110
+ */
111
+ export declare function assertMediaFollowsTheTask(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
98
112
  /** One provider as the host sees it when freezing a break attempt's participant set. */
99
113
  export interface BreakCandidate {
100
114
  id: string;
package/dist/testing.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { browserSessionKey, sameCapabilities, } from "./index.js";
2
- import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateManifest, validateResult, validateSnapshot, } from "./validation.js";
2
+ import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostMediaState, validateManifest, validateResult, validateSnapshot, } from "./validation.js";
3
3
  export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
4
4
  /**
5
5
  * A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
@@ -142,6 +142,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
142
142
  const violations = [...validateManifest(adapter.manifest)];
143
143
  const events = [];
144
144
  const seen = new Set();
145
+ const stream = new TaskStream();
146
+ let seeded = false;
145
147
  const storedSecrets = new Map();
146
148
  const authentication = await adapter.createAuthenticationSession({
147
149
  ...context,
@@ -154,6 +156,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
154
156
  let connection;
155
157
  let unsubscribe;
156
158
  let unsubscribeAuthentication;
159
+ let unsubscribeMedia;
157
160
  let authenticationState;
158
161
  let login;
159
162
  let disconnectWasClean = false;
@@ -224,6 +227,14 @@ export async function exerciseAdapter(adapter, context, options = {}) {
224
227
  requireCapabilityMethods(connection, state.capabilities);
225
228
  }
226
229
  });
230
+ // The host's media is Omni's output, and a test that hands the adapter a malformed one is
231
+ // testing a host that cannot exist. Its first state and every later one are validated.
232
+ if (context.media !== undefined) {
233
+ violations.push(...validateHostMediaState(context.media.state(), "context.media"));
234
+ unsubscribeMedia = context.media.subscribe(state => {
235
+ violations.push(...validateHostMediaState(state, "context.media"));
236
+ });
237
+ }
227
238
  connection = await adapter.connect(context);
228
239
  const live = connection;
229
240
  // Dial is declared by presence: the capability object carries a destination policy rather
@@ -239,6 +250,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
239
250
  violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader()));
240
251
  if (eventNamesUsers(envelope))
241
252
  requireMethod(live, "describeUsers", "an event publishes a UserId");
253
+ // Cross-event rules apply once the stream has a beginning: the connect snapshot.
254
+ if (seeded)
255
+ violations.push(...stream.apply(envelope));
242
256
  if (typeof envelope?.id === "string") {
243
257
  if (eventIds.has(envelope.id))
244
258
  return;
@@ -249,6 +263,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
249
263
  const snapshot = await connection.snapshot();
250
264
  observeSnapshot(snapshot, seen);
251
265
  violations.push(...validateSnapshot(snapshot, adapter.manifest, "snapshot", reader()));
266
+ stream.seed(snapshot);
267
+ seeded = true;
252
268
  requireCapabilityMethods(live, current().capabilities);
253
269
  if (publishesUserIds(snapshot))
254
270
  requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
@@ -280,6 +296,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
280
296
  catch {
281
297
  clean = false;
282
298
  }
299
+ try {
300
+ unsubscribeMedia?.();
301
+ }
302
+ catch {
303
+ clean = false;
304
+ }
283
305
  try {
284
306
  await connection?.disconnect();
285
307
  }
@@ -518,6 +540,101 @@ export function assertDeniedAndRetriedBreak(approvals) {
518
540
  throw new Error(`Break retry scenario must end granted or in effect, ended ${String(last)}`);
519
541
  }
520
542
  }
543
+ // ---------------------------------------------------------------------------
544
+ // The task stream. Each event is validated on its own; what one event may say about a task
545
+ // depends on what was said before, and only something that watched the whole stream can hold a
546
+ // provider to it. A task is never its audio: media ends only on work that has begun, and what
547
+ // follows the media ending is the work completing or ending, never a phase the audio decided.
548
+ // ---------------------------------------------------------------------------
549
+ const WORK_BEGUN = new Set(["in-progress", "paused", "completing"]);
550
+ /** What a stream has said about the tasks it carries, and the rules across events. */
551
+ export class TaskStream {
552
+ tasks = new Map();
553
+ /** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
554
+ seed(snapshot) {
555
+ this.tasks.clear();
556
+ if (!isRecord(snapshot) || !Array.isArray(snapshot.tasks))
557
+ return;
558
+ for (const task of snapshot.tasks) {
559
+ if (isRecord(task) && typeof task.id === "string")
560
+ this.tasks.set(task.id, { phase: String(task.phase), mediaEnded: false });
561
+ }
562
+ }
563
+ /** Applies one envelope and returns what it may not say given what came before. */
564
+ apply(envelope, path = "event") {
565
+ const found = [];
566
+ const event = isRecord(envelope) ? envelope.event : undefined;
567
+ if (!isRecord(event))
568
+ return found;
569
+ const at = `${path}.event`;
570
+ const refuse = (rule, where, message) => found.push({ rule, path: where, message });
571
+ const id = typeof event.taskId === "string" ? event.taskId : isRecord(event.task) && typeof event.task.id === "string" ? event.task.id : undefined;
572
+ const known = id === undefined ? undefined : this.tasks.get(id);
573
+ switch (event.type) {
574
+ case "snapshot":
575
+ this.seed(event.snapshot);
576
+ break;
577
+ case "task-offered":
578
+ if (id === undefined)
579
+ break;
580
+ if (known !== undefined)
581
+ refuse("stream.taskOffered.duplicate", `${at}.task.id`, `${id} is already on the stream; an offer introduces a task once`);
582
+ this.tasks.set(id, { phase: String(isRecord(event.task) ? event.task.phase : undefined), mediaEnded: false });
583
+ break;
584
+ case "task-updated":
585
+ if (id === undefined)
586
+ break;
587
+ if (known === undefined) {
588
+ refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
589
+ break;
590
+ }
591
+ if (known.mediaEnded) {
592
+ const phase = isRecord(event.task) ? String(event.task.phase) : "";
593
+ if (phase !== "completing") {
594
+ refuse("stream.taskMediaEnded.follow", `${at}.task.phase`, `after its media ended, ${id} completes or ends; ${phase} is a phase the audio does not decide`);
595
+ }
596
+ known.mediaEnded = phase === "completing" ? false : known.mediaEnded;
597
+ }
598
+ known.phase = isRecord(event.task) ? String(event.task.phase) : known.phase;
599
+ break;
600
+ case "task-media-ended":
601
+ if (id === undefined)
602
+ break;
603
+ if (known === undefined) {
604
+ refuse("stream.taskMediaEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
605
+ break;
606
+ }
607
+ if (!WORK_BEGUN.has(known.phase)) {
608
+ 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`);
609
+ }
610
+ known.mediaEnded = true;
611
+ break;
612
+ case "task-ended":
613
+ if (id === undefined)
614
+ break;
615
+ if (known === undefined)
616
+ refuse("stream.taskEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
617
+ this.tasks.delete(id);
618
+ break;
619
+ default:
620
+ break;
621
+ }
622
+ return found;
623
+ }
624
+ }
625
+ /**
626
+ * The media follows the task and never decides it. Given a provider's stream -- optionally seeded
627
+ * with the snapshot it began from -- every task is introduced once, `task-media-ended` names a
628
+ * task whose work has begun, and what follows it is `completing` or `task-ended`.
629
+ */
630
+ export function assertMediaFollowsTheTask(envelopes, snapshot) {
631
+ const stream = new TaskStream();
632
+ if (snapshot !== undefined)
633
+ stream.seed(snapshot);
634
+ const found = [];
635
+ envelopes.forEach((envelope, index) => found.push(...stream.apply(envelope, `envelopes[${index}]`)));
636
+ assertNoViolations(found, "The media follows the task");
637
+ }
521
638
  const usableLogin = (status) => status === "authenticated" || status === "refreshing";
522
639
  /**
523
640
  * The participant set of a break attempt is every connected provider from which the agent can
@@ -38,6 +38,12 @@ export interface ReaderContext {
38
38
  export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
39
39
  export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
40
40
  export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
41
+ /**
42
+ * Validates the host's media state as Omni publishes it to an adapter. This is Omni's output, so
43
+ * the check belongs to the host's own tests and to the harness, which validates whatever media a
44
+ * test hands the adapter.
45
+ */
46
+ export declare function validateHostMediaState(state: unknown, path?: string): ProtocolViolation[];
41
47
  /** The connection methods whose results `validateResult` knows. */
42
48
  export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia";
43
49
  /**
@@ -1144,6 +1144,35 @@ function validateFailureInto(value, path, into) {
1144
1144
  into.require(typeof value.retryAfterMs === "number" && Number.isFinite(value.retryAfterMs) && value.retryAfterMs >= 0, "failure.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
1145
1145
  }
1146
1146
  }
1147
+ /**
1148
+ * Validates the host's media state as Omni publishes it to an adapter. This is Omni's output, so
1149
+ * the check belongs to the host's own tests and to the harness, which validates whatever media a
1150
+ * test hands the adapter.
1151
+ */
1152
+ export function validateHostMediaState(state, path = "media") {
1153
+ const into = new Collector();
1154
+ if (!isPlainObject(state)) {
1155
+ into.add("media.shape", path, "a host media state must be an object");
1156
+ return into.violations;
1157
+ }
1158
+ if (state.status === "ready") {
1159
+ into.require(typeof state.localAudio === "object" && state.localAudio !== null, "media.localAudio", `${path}.localAudio`, "ready carries the captured microphone");
1160
+ into.require(state.failure === undefined, "media.failure.unexpected", `${path}.failure`, "ready carries no failure");
1161
+ }
1162
+ else if (state.status === "unavailable") {
1163
+ if (state.failure === undefined) {
1164
+ into.add("media.failure.required", `${path}.failure`, "unavailable carries the failure that says why");
1165
+ }
1166
+ else {
1167
+ validateFailureInto(state.failure, `${path}.failure`, into);
1168
+ }
1169
+ into.require(state.localAudio === undefined, "media.localAudio.unexpected", `${path}.localAudio`, "unavailable carries no microphone");
1170
+ }
1171
+ else {
1172
+ into.add("media.status", `${path}.status`, `a host media state is ready or unavailable, not ${String(state.status)}`);
1173
+ }
1174
+ return into.violations;
1175
+ }
1147
1176
  // Pinned to the result unions: each method's one success status, and the status that carries a
1148
1177
  // failure. A method added to `Connection` without a row here is a compile error at the call site.
1149
1178
  const RESULT_STATUSES = {
package/guide.md CHANGED
@@ -245,10 +245,20 @@ type AuthenticationFailure = {
245
245
  field?: string;
246
246
  };
247
247
 
248
+ type HostMediaState =
249
+ | { status: "ready"; localAudio: MediaStream }
250
+ | { status: "unavailable"; failure: ProtocolFailure };
251
+
252
+ type HostMedia = {
253
+ state(): HostMediaState;
254
+ subscribe(listener: (state: HostMediaState) => void): Unsubscribe;
255
+ };
256
+
248
257
  type ConnectContext = {
249
258
  protocolVersion: number;
250
259
  sessionId: string;
251
260
  autoAcceptTasks?: boolean;
261
+ media?: HostMedia;
252
262
  signal?: AbortSignal;
253
263
  log?: (entry: unknown) => void;
254
264
  };
@@ -1491,6 +1501,7 @@ Creates one live provider connection for the signed-in agent.
1491
1501
  | `protocolVersion` | Version negotiated before authentication. Fixed for this login. |
1492
1502
  | `sessionId` | Omni-generated identity for this login. It is the same value passed as `AuthenticationContext.sessionId`, so an adapter can correlate this connection with the session that authenticated it. Stable across transport reconnects and changed only by a new login. |
1493
1503
  | `autoAcceptTasks` | Agent provisioning policy relayed to the provider at login. Treated as `true` when omitted. When `true`, `task-offered` carries an `acceptanceMode`; when `false`, every task requires agent acceptance. |
1504
+ | `media` | The host's audio, present on a voice connection: `HostMedia`, observable for the life of the connection. See **Host media**. |
1494
1505
  | `signal` | Optional cancellation signal. Stop startup promptly when aborted and do not begin new work. |
1495
1506
  | `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
1496
1507
 
@@ -1789,6 +1800,25 @@ allocation's `acceptanceMode`, moving the task from `pending` to `confirmed`. Th
1789
1800
  subsequent transitions to `preparing` or `in-progress`; Omni does not infer them from the acceptance
1790
1801
  command.
1791
1802
 
1803
+ **A task is never its audio.** A voice task is the allocation: the call is offered when it is
1804
+ routed to the agent and accepted as `acceptanceMode` dictates, and its presence and phase follow
1805
+ the provider's reports about the work — never the audio. Wherever audio moves — an offer, a hold, a
1806
+ consult, a conference leg joining or leaving, a transfer, a callback — the media follows
1807
+ separately, attaching through `openMedia` and ending with `task-media-ended`. Omni does not ring,
1808
+ bridge, or hold a line. How the phone rings, whether it rings at all, and where legs join and leave
1809
+ are the adapter's and the platform's, transient, and decide neither when a task exists nor what
1810
+ phase it is in.
1811
+
1812
+ The line runs between the provider's word and Omni's own senses. `task-media-ended` is the
1813
+ provider's report that primary handling ended — a fact about the work, which is why the completion
1814
+ allowance starts on it and the callback control appears on it — and Omni follows that report as it
1815
+ follows any other. What Omni never does is derive a task's state from its own media session: a
1816
+ stream that drops, a track that ends, a transport that disconnects, a microphone that fails, an
1817
+ endpoint re-registering change nothing about the task until the provider says so. Structurally:
1818
+ `task-media-ended` names a task whose work has begun, what follows it is `completing` or
1819
+ `task-ended`, and every task is introduced once — `exerciseAdapter` holds the stream to that from
1820
+ the connect snapshot on, and `assertMediaFollowsTheTask` holds any sequence.
1821
+
1792
1822
  #### Completion timing
1793
1823
 
1794
1824
  `completionMode` determines how completion is triggered. With `agent-command`, the provider keeps
@@ -2796,26 +2826,50 @@ Omni, and Omni registers the endpoint for it.
2796
2826
 
2797
2827
  That removes a whole class of state the provider would otherwise own and Omni would have to track,
2798
2828
  and it removes the branch that came with it: no command has to ask where the audio went before
2799
- deciding who performs it.
2829
+ deciding who performs it. Nor does the audio ever stand in for the task: a task's presence and
2830
+ phase follow the provider's reports about the work, and the media — attaching, moving through a
2831
+ hold, a consult, a conference or a transfer, and ending — is transient beside it. See **A task is
2832
+ never its audio** under **Task allocation lifecycle**.
2833
+
2834
+ ### Host media
2835
+
2836
+ Omni facilitates the microphone and does not take responsibility for its failure. It captures the
2837
+ agent's microphone once as the voice connection opens, so the permission prompt lands while the
2838
+ agent is signing in rather than over a contact; it prompts, retries on the agent's request, and
2839
+ tells the agent what failed. What it does not do is decide for the adapter what a missing
2840
+ microphone means. `ConnectContext.media` carries the host's audio as a `HostMedia`, observable for
2841
+ the life of the connection the way authentication is:
2842
+
2843
+ | State | Contract |
2844
+ | --- | --- |
2845
+ | `ready` | Omni has the microphone; `localAudio` is it, and the same stream `openMedia` receives. |
2846
+ | `unavailable` | Omni does not: permission refused, no device, capture lost. `failure` says which, in words Omni has already shown the agent. |
2847
+
2848
+ Omni republishes the state whenever it changes — a permission granted late, a device unplugged —
2849
+ and the adapter does what its platform needs. A platform that bridges audio without a host-side
2850
+ input carries on; one that needs it may put the agent not-ready with the platform, refuse calls,
2851
+ or answer `openMedia` `unavailable` with a failure Omni shows. The choice is the adapter's because
2852
+ only the adapter knows where its platform's audio lands.
2800
2853
 
2801
2854
  ### Capacity around setup
2802
2855
 
2803
2856
  Connecting is not the same as being able to take a call. A provider that treats a live connection
2804
- as reachability opens a window where it believes the agent is available and Omni cannot yet carry
2805
- audioits endpoint unregistered, the microphone permission not yet granted.
2857
+ as reachability opens a window where it believes the agent is available and the agent is not yet
2858
+ set up the adapter's own registration incomplete, its credentials not yet renewed.
2806
2859
 
2807
- Nothing closes that window, because nothing opens it: **Omni states no capacity until the agent is
2808
- set up**, and **Work is pulled, never pushed** makes an allocation with none stated a violation. A
2809
- voice connection therefore carries no capacity from the moment it opens until its media is ready,
2810
- and the provider allocates nothing in between.
2860
+ Nothing closes that window, because nothing opens it: **Omni states no capacity until the
2861
+ connection is established**, and **Work is pulled, never pushed** makes an allocation with none
2862
+ stated a violation. Capacity does not wait on the microphone: whether an agent without host audio
2863
+ can take calls is the platform's question, answered by the adapter from **Host media**, not by
2864
+ Omni withholding capacity for every platform alike.
2811
2865
 
2812
- Capacity follows **automatically** once setup completes; the agent does not press anything to
2813
- become available.
2866
+ Capacity follows **automatically** once the connection is established; the agent does not press
2867
+ anything to become available.
2814
2868
 
2815
2869
  | Situation | What Omni sends |
2816
2870
  | --- | --- |
2817
- | Connected, media not ready | Nothing. No capacity has been stated, so nothing may be allocated. |
2818
- | Set up and idle | `setCapacity({ count: n })` |
2871
+ | Connecting | Nothing. No capacity has been stated, so nothing may be allocated. |
2872
+ | Connected and idle | `setCapacity({ count: n })`, and the host media state alongside it. |
2819
2873
  | A task starts or ends | Nothing. The provider counts its own against the ceiling. |
2820
2874
  | The agent's provisioned capacity changes | `setCapacity({ count: n })` |
2821
2875
  | Agent asks for a break | `requestBreak`. Capacity is unchanged and work continues. |
@@ -2841,7 +2895,8 @@ The adapter speaks whatever its platform speaks — SIP over WebSocket, a vendor
2841
2895
  WebRTC — and **none of that appears in this contract**. Registration, signalling, credential
2842
2896
  renewal and reconnect are the adapter's, exactly as its authentication and transport already
2843
2897
  are. Omni owns what belongs to the host: the microphone, the output element, mute, and when a
2844
- session ends.
2898
+ session ends — owning the microphone meaning capturing it, prompting, retrying and saying how it
2899
+ stands, never deciding for the adapter what a missing one means (see **Host media**).
2845
2900
 
2846
2901
  | Member | Contract |
2847
2902
  | --- | --- |
@@ -2849,9 +2904,10 @@ session ends.
2849
2904
  | `setMuted(muted)` | Mutes the agent's microphone on this session. |
2850
2905
  | `close()` | Releases the session. Omni calls it when the task ends. |
2851
2906
 
2852
- `localAudio` is the agent's microphone, captured once by Omni as the voice connection opens so the
2853
- permission prompt lands while the agent is signing in rather than over a ringing contact. A
2854
- provider that bridges audio without a host-side input may ignore it.
2907
+ `localAudio` is the agent's microphone as Omni captured it, the same stream **Host media** reports,
2908
+ and absent while that state is `unavailable`. A provider that bridges audio without a host-side
2909
+ input may ignore it; one that needs it and finds it absent answers `unavailable` with a failure
2910
+ Omni shows the agent.
2855
2911
 
2856
2912
  **A task-scoped session does not oblige one call per task.** A platform holding a nailed-up
2857
2913
  leg for a whole shift may return the same session for every task and release the underlying
@@ -3181,6 +3237,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
3181
3237
  | `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
3182
3238
  | `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
3183
3239
  | `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
3240
+ | `validateHostMediaState(state)` | The host's own media state as published to an adapter: `ready` with the microphone, or `unavailable` with the failure that says why. The harness validates whatever media a test hands the adapter. |
3184
3241
  | `validateResult(result, method)` | What a connection method answered: the status it gives, a failure where the status says so and nowhere else, the failure's shape, and that an `omni.` code is one this contract names. |
3185
3242
  | `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. |
3186
3243
 
@@ -3267,6 +3324,7 @@ cannot be established from TypeScript structure alone.
3267
3324
  | `assertReached(result, subjects)` | The exercise met every subject named; throws listing those it did not. Pair it with a clean `exerciseAdapter` result. |
3268
3325
  | `assertAuthenticationRestoreAndExpiry(states)` | A restored authenticated session can refresh and ends in expiry. Every state is validated. |
3269
3326
  | `assertReconnectWithMissedAssignments(before, reconnect, ids)` | A reconnect snapshot restores assignments received while offline. |
3327
+ | `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.*`). |
3270
3328
  | `assertBreakParticipants(candidates, participants)` | A break attempt asks every usable provider holding capacity, `refreshing` included, and nothing of a provider whose login is `expired`. |
3271
3329
  | `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. |
3272
3330
  | `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.16",
3
+ "version": "0.1.17",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",