@xema/omni-protocol 0.1.24 → 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
@@ -1006,6 +1006,8 @@ export interface Snapshot<C extends Channel = Channel> {
1006
1006
  break: BreakState;
1007
1007
  /** Every task currently owned by this agent for this provider. */
1008
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;
1009
1011
  contacts?: Contact[];
1010
1012
  scheduledActivities?: ScheduledActivity[];
1011
1013
  team?: TeamRoster;
package/dist/testing.js CHANGED
@@ -637,8 +637,16 @@ export class TaskStream {
637
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`);
638
638
  }
639
639
  }
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));
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
+ }
642
650
  break;
643
651
  case "task-media-ready":
644
652
  if (id === undefined)
@@ -993,6 +993,12 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
993
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}`);
994
994
  }
995
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
+ }
996
1002
  if (!Array.isArray(snapshot.tasks)) {
997
1003
  into.add("snapshot.tasks.shape", `${path}.tasks`, "a snapshot must carry a tasks array");
998
1004
  }
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;
@@ -1223,6 +1224,17 @@ which field says so varies. See **Which commands need a capability**.
1223
1224
 
1224
1225
  Snapshots establish and replace provider state when an agent signs in, reconnects, or resynchronises.
1225
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
+
1226
1238
  Events report completed transactions after that baseline. Nothing is missed while the connection
1227
1239
  holds; when it drops, the reconnect snapshot re-establishes the baseline before any further event
1228
1240
  is applied.
@@ -1837,6 +1849,7 @@ a capability it agrees with the login: a lead's snapshot carries `team`, nobody
1837
1849
  | `sessionId` | Identity of this login session. It must match the connection context. |
1838
1850
  | `break` | Complete break state, including approval, accepting state, reasons, retry details, and any imposed break. |
1839
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. |
1840
1853
  | `contacts` | Required complete contact contribution when the manifest declares `contacts`; `[]` clears it. Omitted only when it does not. |
1841
1854
  | `scheduledActivities` | Required complete calendar contribution when the manifest declares `calendar`; `[]` clears it. Omitted only when it does not. |
1842
1855
  | `team` | Required `TeamRoster` when the login declares `capabilities.team`, `[]` when nobody is in it. Forbidden otherwise — the login is the permission. |
@@ -3402,7 +3415,10 @@ silently lose a message, so while the connection is up Omni has seen everything
3402
3415
  Loss has exactly one shape: the connection went away. The adapter reports `connecting` or `error`,
3403
3416
  reconnects, and emits a `snapshot` event carrying complete state. That snapshot is the repair —
3404
3417
  whatever was missed while the connection was down is in it, and Omni replaces its provider view
3405
- 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**.
3406
3422
 
3407
3423
  A snapshot must account for **everything the adapter has emitted before it resolves**, not merely
3408
3424
  everything emitted when it was requested. Omni discards events buffered during the read on that
@@ -3517,6 +3533,13 @@ names a task whose work has begun, and it alternates with `task-media-ended`: me
3517
3533
  made ready cannot end, so a live call whose provider says nothing about its audio is a provider in
3518
3534
  breach, not a state a desk fills in from its own devices.
3519
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
+
3520
3543
  ### `task-media-ended`
3521
3544
 
3522
3545
  Signals that a task's real-time media ended. For voice and similar channels, this starts the fixed
@@ -3610,7 +3633,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
3610
3633
  | --- | --- |
3611
3634
  | `validateManifest(manifest)` | Identity, protocol-version interoperability, authentication methods, and idle-capability shapes. |
3612
3635
  | `validateTask(task, { channel })` | Identity, channel agreement, phase, completion allowance, capability shapes, custom controls, and browsers. |
3613
- | `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. |
3614
3637
  | `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
3615
3638
  | `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
3616
3639
  | `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.24",
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",