@xema/omni-protocol 0.1.123 → 0.1.125

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
@@ -286,6 +286,14 @@ export interface AuthenticationContext {
286
286
  * a voice provider and absent for any other. The same value arrives later as `ConnectContext.phone`.
287
287
  */
288
288
  phone?: Phone;
289
+ /**
290
+ * Where the provider's sign-in page sends the agent back, fixed for this host and adapter so the
291
+ * provider registers it once and its issuer compares it exactly (RFC 9700). Required when the
292
+ * host offers `browser-sso`. One of RFC 8252's three forms: `https`, a reverse-domain private-use
293
+ * scheme such as `com.example.omni:/auth/acme-voice`, or loopback `http://127.0.0.1:<port>/...`,
294
+ * where only the port varies. Never minted per flow: the flow travels in OAuth `state`.
295
+ */
296
+ redirectUri?: string;
289
297
  /** Scoped to this provider's manifest id. */
290
298
  secrets: SecretStore;
291
299
  signal?: AbortSignal;
@@ -389,7 +397,6 @@ export type AuthenticationChallenge = {
389
397
  export type StartAuthenticationRequest = {
390
398
  requestId: string;
391
399
  method: "browser-sso";
392
- callbackUrl: string;
393
400
  } | {
394
401
  requestId: string;
395
402
  method: "credentials";
@@ -401,10 +408,15 @@ export type StartAuthenticationResult = {
401
408
  status: "rejected";
402
409
  failure: AuthenticationFailure;
403
410
  };
404
- export type CompleteAuthenticationRequest = {
405
- flowId: string;
411
+ export type CompleteAuthenticationRequest =
412
+ /**
413
+ * The whole URL the browser came back to, query included. It names no flow: the adapter finds
414
+ * its flow by the `state` it put in the authorization request, and refuses a state it never issued.
415
+ */
416
+ {
406
417
  method: "browser-sso";
407
418
  callbackUrl: string;
419
+ flowId?: never;
408
420
  } | {
409
421
  flowId: string;
410
422
  method: "credentials";
@@ -678,8 +690,18 @@ export type ConnectContext = {
678
690
  * Mute is tied to the phone by the arms below.
679
691
  */
680
692
  host: Host;
693
+ /** The agent application giving up on this sign-in: stop promptly and reject `connect()` with `signal.reason`. */
681
694
  signal?: AbortSignal;
682
695
  log?: (entry: unknown) => void;
696
+ /**
697
+ * The transport-status `connecting` arm, for while `connect()` is still pending and there is no
698
+ * Connection to carry it: a seat limit, a sign-in queue. Callable only until `connect()` settles;
699
+ * from then on the Connection's own `transport-status` speaks. Optional for hosts.
700
+ */
701
+ transportStatus?: (update: {
702
+ status: "connecting";
703
+ message?: string;
704
+ }) => void;
683
705
  } & (
684
706
  /**
685
707
  * On a softphone the host holds the microphone and states what its Mute does; on a hardphone,
@@ -2095,6 +2117,20 @@ export declare const HISTORY_STEPS_WITH_A_PERSON: readonly ["offered", "answered
2095
2117
  /** The steps a dial writes, and so the only ones that carry a `dialId` and a `destinationId`. */
2096
2118
  export declare const HISTORY_STEPS_THAT_DIAL: readonly ["transferred", "conferenced", "unanswered"];
2097
2119
  export declare const historyStepDials: (step: HistoryStep) => boolean;
2120
+ /**
2121
+ * Consecutive entries of one step, shown as one span. The record keeps an entry per occurrence --
2122
+ * two holds are counted as two -- and this is how the agent sees them: once, from the first
2123
+ * entry's `at`, lasting the sum of their `seconds` when every entry states one, and with no
2124
+ * duration when any does not, never a sum that silently leaves a running leg out.
2125
+ */
2126
+ export interface HistorySpan {
2127
+ step: HistoryStep;
2128
+ at: IsoTimestamp;
2129
+ seconds?: DurationSeconds;
2130
+ entries: TaskHistoryStep[];
2131
+ }
2132
+ /** Groups a record's consecutive same-step entries into spans, oldest first. */
2133
+ export declare function historySpans(steps: readonly TaskHistoryStep[]): HistorySpan[];
2098
2134
  /** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
2099
2135
  export declare function historyStepExpectsAPerson(step: HistoryStep): boolean;
2100
2136
  export interface BrowserSessionKeyInput {
package/dist/index.js CHANGED
@@ -201,6 +201,23 @@ export const historyStepDials = (step) => HISTORY_STEPS_THAT_DIAL.includes(step)
201
201
  // Pinned both ways: a step added to `HistoryStep` has to be placed here, and a step listed here
202
202
  // has to exist there. `satisfies` on the list covers the second; this statement covers the first.
203
203
  true;
204
+ /** Groups a record's consecutive same-step entries into spans, oldest first. */
205
+ export function historySpans(steps) {
206
+ const spans = [];
207
+ for (const entry of steps) {
208
+ const last = spans[spans.length - 1];
209
+ if (last !== undefined && last.step === entry.step)
210
+ last.entries.push(entry);
211
+ else
212
+ spans.push({ step: entry.step, at: entry.at, entries: [entry] });
213
+ }
214
+ for (const span of spans) {
215
+ if (span.entries.every(entry => entry.seconds !== undefined)) {
216
+ span.seconds = span.entries.reduce((total, entry) => total + entry.seconds, 0);
217
+ }
218
+ }
219
+ return spans;
220
+ }
204
221
  /** Whether an absent `by` means "could not attribute" rather than "nobody was involved". */
205
222
  export function historyStepExpectsAPerson(step) {
206
223
  return HISTORY_STEPS_WITH_A_PERSON.includes(step);
package/dist/testing.js CHANGED
@@ -379,8 +379,18 @@ export async function testAdapter(adapter, context, options = {}) {
379
379
  report: () => { consulted.report = true; return context.host.report(); },
380
380
  subscribe: listener => { consulted.subscribe = true; return context.host.subscribe(listener); },
381
381
  };
382
- const connected = { ...context, host, store: watched.store };
383
- connection = await adapter.connect(connected);
382
+ // A cancelled sign-in fails with the cancellation's own reason. The probe hands connect() a
383
+ // signal already aborted, so the answer is exact and owes nothing to the machine's speed; it
384
+ // gets the host and store unwrapped, so what it consults is not counted for the real connect.
385
+ violations.push(...await cancelledConnect(adapter, context));
386
+ const connecting = connectingReports({ ...context, host, store: watched.store }, violations);
387
+ const connected = connecting.context;
388
+ try {
389
+ connection = await adapter.connect(connected);
390
+ }
391
+ finally {
392
+ connecting.settled();
393
+ }
384
394
  // The standing client: the first connection, then the second after a hand-over. Everything the
385
395
  // harness asks of a connection it asks of the one that stands.
386
396
  let live = connection;
@@ -892,6 +902,56 @@ function refreshingCarriesOver(login, state, path) {
892
902
  }
893
903
  return found;
894
904
  }
905
+ /** Calls `connect()` on an already-cancelled signal and names an adapter that connects anyway or fails for another reason. */
906
+ async function cancelledConnect(adapter, context) {
907
+ const cancel = new AbortController();
908
+ cancel.abort(new Error("the agent application cancelled this sign-in"));
909
+ const { transportStatus: _unused, ...rest } = context;
910
+ try {
911
+ const stray = await adapter.connect({ ...rest, signal: cancel.signal });
912
+ try {
913
+ await stray.disconnect();
914
+ }
915
+ catch { /* the connection that should not exist is ended as far as it can be */ }
916
+ return [{ rule: "connect.signal.aborted", path: "connect.signal",
917
+ message: "connect() resolved on a signal already aborted; a cancelled sign-in fails with signal.reason" }];
918
+ }
919
+ catch (error) {
920
+ return error === cancel.signal.reason ? [] : [{ rule: "connect.signal.reason", path: "connect.signal",
921
+ message: `connect() on an aborted signal failed with ${String(error)}, not with signal.reason` }];
922
+ }
923
+ }
924
+ /**
925
+ * The host's `transportStatus`, handed on so that each report is checked: the `connecting` arm,
926
+ * and only while `connect()` is pending. After it settles the connection carries the status.
927
+ */
928
+ function connectingReports(context, violations) {
929
+ const report = context.transportStatus;
930
+ if (report === undefined)
931
+ return { context, settled: () => { } };
932
+ let settled = false;
933
+ return {
934
+ context: {
935
+ ...context,
936
+ transportStatus: (update) => {
937
+ const at = "connect.transportStatus";
938
+ if (settled) {
939
+ violations.push({ rule: "connect.transportStatus.late", path: at,
940
+ message: "reported through the context after connect() settled; the connection's own transport-status speaks from then on" });
941
+ }
942
+ if (!isRecord(update) || update.status !== "connecting") {
943
+ violations.push({ rule: "connect.transportStatus.status", path: `${at}.status`,
944
+ message: `before a connection exists the only status is connecting, not ${JSON.stringify(isRecord(update) ? update.status : update)}` });
945
+ }
946
+ else if (update.message !== undefined && (typeof update.message !== "string" || update.message.trim() === "")) {
947
+ violations.push({ rule: "connect.transportStatus.message", path: `${at}.message`, message: "a message must not be empty when present" });
948
+ }
949
+ report(update);
950
+ },
951
+ },
952
+ settled: () => { settled = true; },
953
+ };
954
+ }
895
955
  /** Validates each state of a published sequence, and that every `refreshing` carries over the login before it. */
896
956
  function assertLoginSequence(states, summary) {
897
957
  const found = [];
@@ -1640,7 +1700,14 @@ async function testOneCall(call) {
1640
1700
  catch { /* the session that would not restore is closed as far as it can be */ }
1641
1701
  return;
1642
1702
  }
1643
- const second = await again.connect(call.context);
1703
+ const reconnecting = connectingReports(call.context, found);
1704
+ let second;
1705
+ try {
1706
+ second = await again.connect(reconnecting.context);
1707
+ }
1708
+ finally {
1709
+ reconnecting.settled();
1710
+ }
1644
1711
  // From here the second client is the run's connection, held to everything the first was on
1645
1712
  // connect: what it publishes is validated as before.
1646
1713
  await call.handOver.up(restoredSession, second);
@@ -193,6 +193,12 @@ export interface LoginValidationContext {
193
193
  levels?: readonly string[];
194
194
  }
195
195
  export declare function validateAuthenticationFailure(failure: unknown, path?: string): ProtocolViolation[];
196
+ /**
197
+ * A redirect URI in one of RFC 8252's three forms. A private-use scheme is reverse-domain (§7.1),
198
+ * a loopback is an IP literal rather than `localhost` (§8.3) and may vary only its port (§7.3),
199
+ * and none carries a fragment, which the authorization response could not be appended beside.
200
+ */
201
+ export declare function validateRedirectUri(value: unknown, path?: string): ProtocolViolation[];
196
202
  /**
197
203
  * What `start()` or `complete()` answered: a challenge or a rejection from `start`, a login or a
198
204
  * rejection from `complete`. A rejection carries a failure the host can show, and a login is held
@@ -3090,6 +3090,33 @@ export function validateAuthenticationFailure(failure, path = "failure") {
3090
3090
  validateAuthenticationFailureInto(failure, path, into);
3091
3091
  return into.violations;
3092
3092
  }
3093
+ /**
3094
+ * A redirect URI in one of RFC 8252's three forms. A private-use scheme is reverse-domain (§7.1),
3095
+ * a loopback is an IP literal rather than `localhost` (§8.3) and may vary only its port (§7.3),
3096
+ * and none carries a fragment, which the authorization response could not be appended beside.
3097
+ */
3098
+ export function validateRedirectUri(value, path = "redirectUri") {
3099
+ const into = new Collector();
3100
+ if (!into.filled(value, "redirectUri", path, "browser SSO needs the fixed redirect URI the provider registered"))
3101
+ return into.violations;
3102
+ let url;
3103
+ try {
3104
+ url = new URL(value);
3105
+ }
3106
+ catch {
3107
+ into.add("redirectUri", path, `${describeValue(value)} is not an absolute URI`);
3108
+ return into.violations;
3109
+ }
3110
+ into.require(url.hash === "" && !value.includes("#"), "redirectUri.fragment", path, "a redirect URI carries no fragment");
3111
+ const scheme = url.protocol.slice(0, -1);
3112
+ if (scheme === "http") {
3113
+ into.require(url.hostname === "127.0.0.1" || url.hostname === "[::1]", "redirectUri.http", path, "plain http is for loopback only, as 127.0.0.1 or [::1] -- not localhost, which may resolve elsewhere");
3114
+ }
3115
+ else if (scheme !== "https") {
3116
+ into.require(/^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+$/.test(scheme), "redirectUri.scheme", path, `a private-use scheme is a reverse domain name the host controls, as com.example.omni; ${describeValue(scheme)} is not`);
3117
+ }
3118
+ return into.violations;
3119
+ }
3093
3120
  function validateAuthenticationChallenge(value, path, into) {
3094
3121
  if (!isPlainObject(value)) {
3095
3122
  into.add("authentication.result.challenge", path, "interaction-required carries the challenge to put to the person");
package/guide/agent.md CHANGED
@@ -443,13 +443,16 @@ arrive omits it rather than guessing an instant. Omni shows it as *Incoming call
443
443
  Amazon Connect keeps an initiation time, Genesys Cloud a conversation start and NICE CXone a contact
444
444
  start, separate from the queue.
445
445
 
446
- **Time with the IVR or a bot is `self-service`.** The caller pressing through a menu, talking to a
447
- voice bot, or a chat customer with a bot before a person takes over: one step for all of them, on
448
- every channel, the way Genesys Cloud and NICE CXone report IVR and bot time together as
449
- self-service. Nobody from the provider takes part, so it has no `by`. `at` is when the customer
450
- entered it and `seconds`, once it ended, how long they spent there; a customer sent back to a menu
451
- after a transfer has a second entry, one per visit, as a second queue is a second `queued`. A
452
- customer that words it *IVR* for its agents does so in `labels.historySteps`.
446
+ **Time the platform handles the customer before a queue is `self-service`.** The caller pressing
447
+ through a menu, talking to a voice bot, hearing an announcement, or waiting while the platform
448
+ looks them up; a chat customer with a bot before a person takes over: one step for all of them, on
449
+ every channel. Genesys Cloud and NICE CXone report IVR and bot time as self-service; here it also
450
+ takes in the routing time Genesys Cloud keeps as a separate system segment, so a provider that
451
+ measures the gap before each queue without knowing what played in it reports it here rather than
452
+ nowhere. It is therefore not evidence that the customer used a menu. Nobody from the provider takes
453
+ part, so it has no `by`. `at` is when it began and `seconds`, once it ended, how long it lasted; a
454
+ customer routed again after a transfer has a second entry, one per visit, as a second queue is a
455
+ second `queued`. A customer that words it *IVR* for its agents does so in `labels.historySteps`.
453
456
 
454
457
  **A callback carries the call it comes back to.** When a caller asks to be called back, or an agent
455
458
  arranges it, the provider writes a `callback-requested` step: `by` names the agent who arranged it, and
@@ -483,7 +486,13 @@ provider closed it. One publisher of the closed leg, and no order between them t
483
486
  hold, at the entry's `at` plus its `seconds`, and nothing is lost by not naming it twice. The same goes for every step: two mutes are two
484
487
  `muted` entries, and a call that joined two queues -- a menu's, then this one -- has two `queued`
485
488
  entries, one per join. Order is enforced: an entry earlier than the one before it is refused
486
- (`task.history.order`). **Intervals between steps are the agent application's to subtract**, never a
489
+ (`task.history.order`).
490
+
491
+ **Shown as spans.** The record keeps one entry per occurrence, because leads count them — two holds
492
+ are two holds, the way Amazon Connect counts holds per contact, and two queues say which queue the
493
+ call waited in. The agent sees consecutive entries of one step as one span: `historySpans(steps)`
494
+ groups them, as Genesys Cloud keeps segments apart in its record and joins them in the agent's
495
+ timeline. A provider never merges entries to get that look. **Intervals between steps are the agent application's to subtract**, never a
487
496
  total the provider adds: *time to offer*, from the call joining its queue to the agent's screen lighting
488
497
  up, is the `offered` entry's `at` minus the last `queued` entry's, and the ring is outside it.
489
498
  `queueSeconds` keeps the industry's meaning -- the whole wait until somebody answered -- and is not
package/guide.md CHANGED
@@ -290,6 +290,7 @@ type AuthenticationContext = {
290
290
  loginId: string;
291
291
  timeZone: TimeZone;
292
292
  phone?: Phone;
293
+ redirectUri?: string;
293
294
  secrets: SecretStore;
294
295
  signal?: AbortSignal;
295
296
  log?: (entry: unknown) => void;
@@ -427,6 +428,7 @@ type ConnectContext = {
427
428
  host: Host;
428
429
  signal?: AbortSignal;
429
430
  log?: (entry: unknown) => void;
431
+ transportStatus?: (update: { status: "connecting"; message?: string }) => void;
430
432
  } & (
431
433
  | { phone: "softphone"; host: { mute: HostMute } }
432
434
  | { phone?: "hardphone"; host: { mute?: never; recording?: never } }
@@ -447,7 +449,7 @@ type AuthenticationChallenge =
447
449
  | { flowId: string; method: "credentials"; fields: CredentialField[] };
448
450
 
449
451
  type StartAuthenticationRequest =
450
- | { requestId: string; method: "browser-sso"; callbackUrl: string }
452
+ | { requestId: string; method: "browser-sso" }
451
453
  | { requestId: string; method: "credentials" };
452
454
 
453
455
  type StartAuthenticationResult =
@@ -455,7 +457,7 @@ type StartAuthenticationResult =
455
457
  | { status: "rejected"; failure: AuthenticationFailure };
456
458
 
457
459
  type CompleteAuthenticationRequest =
458
- | { flowId: string; method: "browser-sso"; callbackUrl: string }
460
+ | { method: "browser-sso"; callbackUrl: string; flowId?: never }
459
461
  | { flowId: string; method: "credentials"; values: Readonly<Record<string, string>> };
460
462
 
461
463
  type CompleteAuthenticationResult =
@@ -725,6 +727,13 @@ type TaskHistoryStep = {
725
727
  mutedBy?: MutedBy;
726
728
  };
727
729
 
730
+ type HistorySpan = {
731
+ step: HistoryStep;
732
+ at: IsoTimestamp;
733
+ seconds?: DurationSeconds;
734
+ entries: TaskHistoryStep[];
735
+ };
736
+
728
737
  type TaskCompletion =
729
738
  | { completionMode: "agent-command"; wrapAllowance?: DurationSeconds }
730
739
  | { completionMode: "provider-automatic"; wrapAllowance: DurationSeconds };
@@ -2125,17 +2134,32 @@ short-lived, opaque `flowId`; rejection returns `AuthenticationFailure`.
2125
2134
 
2126
2135
  #### Browser SSO
2127
2136
 
2128
- For `browser-sso`, Omni issues a one-time callback URL and passes it to `start()`:
2137
+ **The redirect URI is fixed, never minted per flow.** An issuer that follows current guidance
2138
+ compares the redirect URI exactly against the one registered (RFC 9700 §2.1, §4.1.3), so a path
2139
+ carrying a request id is refused by every one of them. The host states the URI once, for this host
2140
+ and adapter, as `AuthenticationContext.redirectUri`, and the provider registers it on its public
2141
+ client ahead of time. It takes one of the three forms RFC 8252 gives a native or browser app, as
2142
+ Google, Microsoft Entra and Okta register them:
2143
+
2144
+ | Form | Example | When |
2145
+ | --- | --- | --- |
2146
+ | `https` | `https://agent.example.com/auth/acme-voice` | Omni in a browser tab, or a desktop app with a claimed https link. |
2147
+ | Private-use scheme | `com.example.omni:/auth/acme-voice` | A desktop app. The scheme is a reverse domain name the host controls (RFC 8252 §7.1). |
2148
+ | Loopback | `http://127.0.0.1:53127/auth/acme-voice` | A desktop app listening locally. Only the port varies between flows (§7.3); the host is the IP literal, never `localhost` (§8.3). |
2149
+
2150
+ `validateRedirectUri(uri)` refuses anything else: a scheme that is not a reverse domain, such as
2151
+ `omni-agent:` (`redirectUri.scheme`), plain http to any host but the loopback literal
2152
+ (`redirectUri.http`), and a fragment (`redirectUri.fragment`). Its path names the adapter, so one
2153
+ host serves several providers on one scheme.
2154
+
2155
+ `start()` then carries nothing about the callback:
2129
2156
 
2130
2157
  ```ts
2131
- {
2132
- requestId: "auth-42",
2133
- method: "browser-sso",
2134
- callbackUrl: "omni-agent://auth/acme-voice/auth-42"
2135
- }
2158
+ { requestId: "auth-42", method: "browser-sso" }
2136
2159
  ```
2137
2160
 
2138
- The adapter creates the OAuth/OIDC request, including PKCE, `state`, and OIDC `nonce`, and returns:
2161
+ The adapter creates the OAuth/OIDC request with its OAuth redirect URI set to the context's `redirectUri`,
2162
+ PKCE, OIDC `nonce`, and a `state` that names this flow, and returns:
2139
2163
 
2140
2164
  ```ts
2141
2165
  {
@@ -2150,7 +2174,10 @@ The adapter creates the OAuth/OIDC request, including PKCE, `state`, and OIDC `n
2150
2174
  ```
2151
2175
 
2152
2176
  Omni opens the requested `system` or `omni` browser. After redirect, it passes the complete callback
2153
- URL to `complete()`. The adapter validates the flow, exchanges the authorization code, and returns
2177
+ URL, query included, to `complete()` as `{ method: "browser-sso", callbackUrl }`. The request names
2178
+ no flow: **the flow is found by `state`**, which the adapter issued and alone can read, and a
2179
+ `state` it never issued, or already used, is rejected. Omni routes the callback to the adapter by
2180
+ the URI's path and never encodes a request id in it. The adapter validates the flow, exchanges the authorization code, and returns
2154
2181
  the authenticated provider identity. Provider tokens never enter Omni UI or general protocol state.
2155
2182
  An Omni-hosted SSO browser uses a dedicated temporary authentication session and never shares
2156
2183
  cookies or storage with task or personal browsers.
@@ -2288,8 +2315,9 @@ Creates one live provider connection for the signed-in agent.
2288
2315
  | `phone` | The same value passed as `AuthenticationContext.phone`: how this login hears its calls. The type ties `host.mute` to it: a `softphone` login's agent application states what its Mute does, and a hardphone or conversation login's agent application cannot, so the omission is a compile error rather than a live seat's discovery. See **The station is the agent application's** in `guide/phone.md`. |
2289
2316
  | `store` | The login's operational store, kept by the agent application for the life of the login, across a reload of the agent application, and cleared at sign-out: where an adapter that composes a record keeps what its platform cannot hold for it, such as the interaction legs an agent application reported. Three functions, by key. Never for anything sensitive, which is `AuthenticationContext.secrets`, a store an agent application may clear aggressively. **A task's keys carry its assignment id and go with the task**: a key written about a task names the assignment id in the key, and is deleted before the task's end is published, so that nothing of a closed task survives its ending and nothing is left for whatever comes next -- a record with legs the agent application never reported against it. A login-scoped key carries no assignment id and outlives any task. The harness requires the store of every connection (`store.shape`, `store.get`, `.set`, `.delete`), watches the one it hands over, and names a task's key still held after `task-ended` (`test.store.retained`) or written about the task after its end -- a persist hung off a timer that saw the task as it was (`test.store.late`). The ordinary late write is the agent application's, not the adapter's: an agent application reports a leg without waiting for the answer, so an unmute can follow `complete` by a tick, and the adapter answers a report about a task that has ended `failed` with `omni.assignment-not-found` and writes nothing -- the agent application ends its own open legs at the task's end, so such a report is the agent application's error to see, and an agent application reads every `recordStep` answer and awaits the one for the leg it closes at a task's end, the only moment a refusal is expected, since a report nobody waits for is an error nobody can see; an adapter that names no task in its keys gets no cleanup check, which is a gap rather than a pass, never an exemption: the obligation is that nothing of a closed task survives its ending, and an adapter that keeps every open task in one login-scoped value owes exactly that inside the value, where the harness cannot look. One key per task, named for it, is the shape the harness can hold, and the shape to reach for. The store lists nothing, so an adapter that needs to find its tasks keeps a login-scoped index of ids beside them; at the task's end it deletes the body first and reindexes after, since a crash between the two then leaves an index naming a task with no body, which a reader skips, where the other order leaves a body for a task that has ended, which is the hazard itself. A reader of the index tolerates an id with no body as an ending that was underway, not as corruption. |
2290
2317
  | `host` | The agent application's report of the agent's station — devices, permissions, network — to consult before declaring the agent ready to the platform, and on every change. See **The agent application reports, the adapter decides** in `guide/phone.md`. |
2291
- | `signal` | Optional cancellation signal. Stop startup promptly when aborted and do not begin new work. |
2318
+ | `signal` | Optional cancellation signal: the agent application giving up on this sign-in. When it is aborted, stop startup promptly, begin no new work, and reject `connect()` with `signal.reason`, as the web platform's own fetch does. The harness hands `connect()` a signal already aborted and names a connection made anyway (`connect.signal.aborted`) or a failure with anything but `signal.reason` (`connect.signal.reason`). |
2292
2319
  | `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
2320
+ | `transportStatus` | Optional. The `connecting` arm of **`transport-status`**, for while `connect()` is still pending: same shape, same rule that `message` is safe for the agent to see. Callable only until `connect()` settles. See **Before the connection exists**. |
2293
2321
 
2294
2322
  ### The agent's day
2295
2323
 
@@ -2682,6 +2710,19 @@ died; the agent application knows how to run a login. `recovery` joins the two:
2682
2710
  they are signed in elsewhere, with a way to take the login back, which is a `connect()` that
2683
2711
  displaces the other. See **`Adapter.connect(context)`**.
2684
2712
 
2713
+ **Before the connection exists.** A provider can hold a sign-in back before any connection
2714
+ stands: every agent licence on the site in use, a sign-in queue. The adapter retries as
2715
+ **Patience is the agent application's** asks, but until `connect()` resolves the agent application
2716
+ holds no `Connection` to hear `transport-status` from, and a first sign-in to a full site would
2717
+ just hang. So the adapter reports through `ConnectContext.transportStatus` -- `{ status:
2718
+ "connecting", message }`, the same arm and the same safety rule -- and the agent application
2719
+ renders it exactly as it renders `connecting`. Only `connecting`: a sign-in that cannot succeed
2720
+ rejects `connect()`. It is callable only until `connect()` settles, and from then on the
2721
+ connection's own `transport-status` speaks; the harness names a call made later
2722
+ (`connect.transportStatus.late`), a status other than `connecting` (`connect.transportStatus.status`)
2723
+ and an empty message (`connect.transportStatus.message`). A host may omit it, so an adapter calls
2724
+ it only where it is present.
2725
+
2685
2726
  **Patience is the agent application's.** An adapter in `connecting` retries for as long as it takes and never
2686
2727
  has to decide when to stop. Omni owns giving up: after however long it chooses to wait, it may
2687
2728
  call `disconnect()` and either `connect()` afresh or surface the failure — so neither side waits
@@ -2990,6 +3031,14 @@ Whether two logins declare the same capabilities, field by field — key order a
2990
3031
  `team: {}` distinct from `team` absent. It is the comparison an adapter makes before republishing
2991
3032
  `authenticated`, and the one `testAdapter` holds `refreshing` to.
2992
3033
 
3034
+ ### `historySpans(steps)`
3035
+
3036
+ Groups a record's consecutive entries of one step into a `HistorySpan` each — `step`, the first
3037
+ entry's `at`, `seconds` summed when every entry states one and absent when any does not, and the
3038
+ `entries` themselves — so every agent application shows *Self-service · 30s* once where the
3039
+ record has two self-service entries in a row. Only neighbours are grouped: a hold, an answer and
3040
+ another hold are three spans. See **Shown as spans** in `guide/agent.md`.
3041
+
2993
3042
  ## Runtime validation
2994
3043
 
2995
3044
  Structural rules in this document are executable through the runtime validators Omni applies to
@@ -3019,6 +3068,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
3019
3068
  | `validateLoginStore(store)` | The login's store the agent application hands every connection: an object with `get`, `set` and `delete` (`store.shape`, `store.get`, `.set`, `.delete`). The harness holds `ConnectContext.store` to it. |
3020
3069
  | `validateCapacity(capacity)` | What the agent application states as capacity: a whole number of zero or more (`capacity.count`), zero being agent application-stopped. The harness states one on connect and two, one and zero after the test, each answered `applied`, and holds any offer to the count in force (`stream.taskOffered.overCapacity`). |
3021
3070
  | `validateAuthenticationResult(result, method)` | What `start()` or `complete()` answered: a challenge or a rejection, a login or a rejection. A rejection's failure is held to its rules -- an `omni.` code the contract lists, and `omni.phone-not-permitted` never retryable, since the agent's station is configuration. `validateAuthenticationFailure(failure)` is the same check on a failure alone. |
3071
+ | `validateRedirectUri(uri, path?)` | A host's fixed redirect URI in one of RFC 8252's forms: `https`, a reverse-domain private-use scheme, or loopback http to `127.0.0.1` or `[::1]`, with no fragment (`redirectUri.*`). |
3022
3072
  | `validateTaskCommand(command, task?)` | What a command needs to be issuable, against the task it names: its own shape -- a dial's `dialId`, a conference's item, a schedule's time, a remove naming exactly one person -- and, with the task, the capability the table above gates it on (`command.capability.<name>`, `.locked`), the phase it belongs to (`command.phase.*`, `command.phase.interaction` for every control on the call or the conversation), and the state that has to stand: a lead requested, somebody else still on the call (`command.conference.remove.alone`), the caller off hold before the agent's part ends (`command.endCall.held`). An agent application validates before sending and an adapter before acting. |
3023
3073
  | `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. |
3024
3074
  | `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. Omni applies it to every state a session publishes — the republished as much as the first. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.123",
3
+ "version": "0.1.125",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",