@xema/omni-protocol 0.1.53 → 0.1.55
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 +1 -1
- package/dist/index.js +1 -0
- package/dist/testing.js +10 -3
- package/dist/validation.d.ts +7 -0
- package/dist/validation.js +56 -8
- package/guide.md +33 -2
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1394,7 +1394,7 @@ export interface ProviderEventEnvelope<C extends Channel = Channel> {
|
|
|
1394
1394
|
occurredAt: IsoTimestamp;
|
|
1395
1395
|
event: ProviderEvent<C>;
|
|
1396
1396
|
}
|
|
1397
|
-
export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.task-not-found", "omni.destination-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed"];
|
|
1397
|
+
export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.task-not-found", "omni.destination-not-permitted", "omni.phone-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed"];
|
|
1398
1398
|
export type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
|
|
1399
1399
|
export interface Connection<C extends Channel = Channel> {
|
|
1400
1400
|
snapshot(): Snapshot<C> | Promise<Snapshot<C>>;
|
package/dist/index.js
CHANGED
package/dist/testing.js
CHANGED
|
@@ -902,6 +902,10 @@ async function driveOneCall(drive) {
|
|
|
902
902
|
const declared = capabilities[name];
|
|
903
903
|
return declared !== undefined && !(isRecord(declared) && declared.lockedBy !== undefined);
|
|
904
904
|
};
|
|
905
|
+
// Media may arrive any time after the accept, before or after the task's own update says
|
|
906
|
+
// in-progress: the event is the provider's word that the audio should attach, never a reply to
|
|
907
|
+
// openMedia, so the drive looks for it from the accept rather than from the last update it read.
|
|
908
|
+
const acceptedAt = drive.events.length;
|
|
905
909
|
// 1. Accept the offer, if it is one.
|
|
906
910
|
if (latestTask().phase === "pending") {
|
|
907
911
|
if (await send({ type: drive.channel === "voice" ? "answer" : "accept" }) === undefined)
|
|
@@ -929,10 +933,10 @@ async function driveOneCall(drive) {
|
|
|
929
933
|
const started = await waitFor("task-media-started for the driven task", envelope => {
|
|
930
934
|
const event = envelope.event;
|
|
931
935
|
return event.type === "task-media-started" && event.taskId === taskId ? event : undefined;
|
|
932
|
-
},
|
|
936
|
+
}, acceptedAt);
|
|
933
937
|
if (started === undefined)
|
|
934
938
|
return found;
|
|
935
|
-
cursor = started.at;
|
|
939
|
+
cursor = Math.max(cursor, started.at);
|
|
936
940
|
}
|
|
937
941
|
const opened = await drive.connection.openMedia?.({ taskId, localAudio: drive.localAudio });
|
|
938
942
|
found.push(...validateResult(opened, "openMedia", "drive.openMedia"));
|
|
@@ -972,7 +976,10 @@ async function driveOneCall(drive) {
|
|
|
972
976
|
}
|
|
973
977
|
}
|
|
974
978
|
// 6. Complete, where the agent completes; otherwise the provider does, and the drive waits for it.
|
|
975
|
-
|
|
979
|
+
// A conversation has no media to end and no completing phase to wait for: a chat or an email,
|
|
980
|
+
// and a voice task offering no end-call, is completed from where it stands.
|
|
981
|
+
const completable = latestTask().phase === "completing" || latestTask().phase === "in-progress" || latestTask().phase === "paused";
|
|
982
|
+
if (latestTask().completionMode === "agent-command" && completable) {
|
|
976
983
|
const command = { type: "complete" };
|
|
977
984
|
const dispositions = isRecord(latestTask().capabilities) ? latestTask().capabilities.dispositions : undefined;
|
|
978
985
|
if (isRecord(dispositions) && dispositions.required === true && Array.isArray(dispositions.codes) && isRecord(dispositions.codes[0])) {
|
package/dist/validation.d.ts
CHANGED
|
@@ -91,4 +91,11 @@ export interface LoginValidationContext {
|
|
|
91
91
|
/** The level ids in force, from the manifest. The defaults when absent. */
|
|
92
92
|
levels?: readonly string[];
|
|
93
93
|
}
|
|
94
|
+
export declare function validateAuthenticationFailure(failure: unknown, path?: string): ProtocolViolation[];
|
|
95
|
+
/**
|
|
96
|
+
* What `start()` or `complete()` answered: a challenge or a rejection from `start`, a login or a
|
|
97
|
+
* rejection from `complete`. A rejection carries a failure the host can show, and a login is held
|
|
98
|
+
* to the same rules as any `authenticated` state.
|
|
99
|
+
*/
|
|
100
|
+
export declare function validateAuthenticationResult(result: unknown, method: "start" | "complete", path?: string, context?: LoginValidationContext): ProtocolViolation[];
|
|
94
101
|
export declare function validateAuthenticationState(state: unknown, path?: string, context?: LoginValidationContext): ProtocolViolation[];
|
package/dist/validation.js
CHANGED
|
@@ -2053,6 +2053,61 @@ function validateMonitorControl(value, path, into) {
|
|
|
2053
2053
|
});
|
|
2054
2054
|
into.require(value.includes("monitor"), "authentication.capability.team.monitorControl.monitor", path, "whisper and barge begin from a monitor: a lead who may listen in any mode may monitor");
|
|
2055
2055
|
}
|
|
2056
|
+
/**
|
|
2057
|
+
* A failure an authentication reports: on a `rejected` start or completion, or on an `expired`
|
|
2058
|
+
* state. A provider names its own codes freely; an `omni.` code is the contract's and has to be one
|
|
2059
|
+
* it lists. `omni.phone-not-permitted` is never retryable: the agent's station is administrator
|
|
2060
|
+
* configuration, and trying again does not reconfigure it.
|
|
2061
|
+
*/
|
|
2062
|
+
function validateAuthenticationFailureInto(value, path, into) {
|
|
2063
|
+
if (!isPlainObject(value)) {
|
|
2064
|
+
into.add("authentication.failure.shape", path, "a failure must be an object");
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
if (into.filled(value.code, "authentication.failure.code", `${path}.code`, "a failure needs a code") && value.code.startsWith("omni.")) {
|
|
2068
|
+
into.require(OMNI_FAILURE_CODES.includes(value.code), "failure.code.unknown", `${path}.code`, `${String(value.code)} is not a failure code this contract defines`);
|
|
2069
|
+
}
|
|
2070
|
+
into.filled(value.message, "authentication.failure.message", `${path}.message`, "a failure needs a message");
|
|
2071
|
+
into.require(typeof value.retryable === "boolean", "authentication.failure.retryable", `${path}.retryable`, "a failure must say whether it is retryable");
|
|
2072
|
+
if (value.code === "omni.phone-not-permitted") {
|
|
2073
|
+
into.require(value.retryable === false, "authentication.failure.phone.retryable", `${path}.retryable`, "a phone the platform does not permit for this agent is configuration: trying again does not change it, so the refusal is not retryable");
|
|
2074
|
+
}
|
|
2075
|
+
if (value.retryAfterMs !== undefined) {
|
|
2076
|
+
into.require(typeof value.retryAfterMs === "number" && value.retryAfterMs >= 0, "authentication.failure.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
|
|
2077
|
+
}
|
|
2078
|
+
if (value.field !== undefined)
|
|
2079
|
+
into.filled(value.field, "authentication.failure.field", `${path}.field`, "field names a declared credentials field when present");
|
|
2080
|
+
}
|
|
2081
|
+
export function validateAuthenticationFailure(failure, path = "failure") {
|
|
2082
|
+
const into = new Collector();
|
|
2083
|
+
validateAuthenticationFailureInto(failure, path, into);
|
|
2084
|
+
return into.violations;
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* What `start()` or `complete()` answered: a challenge or a rejection from `start`, a login or a
|
|
2088
|
+
* rejection from `complete`. A rejection carries a failure the host can show, and a login is held
|
|
2089
|
+
* to the same rules as any `authenticated` state.
|
|
2090
|
+
*/
|
|
2091
|
+
export function validateAuthenticationResult(result, method, path = "result", context = {}) {
|
|
2092
|
+
const into = new Collector();
|
|
2093
|
+
if (!isPlainObject(result)) {
|
|
2094
|
+
into.add("authentication.result.shape", path, `${method} must answer an object`);
|
|
2095
|
+
return into.violations;
|
|
2096
|
+
}
|
|
2097
|
+
if (result.status === "rejected") {
|
|
2098
|
+
validateAuthenticationFailureInto(result.failure, `${path}.failure`, into);
|
|
2099
|
+
}
|
|
2100
|
+
else if (method === "start" && result.status === "interaction-required") {
|
|
2101
|
+
into.require(isPlainObject(result.challenge), "authentication.result.challenge", `${path}.challenge`, "interaction-required carries the challenge to put to the person");
|
|
2102
|
+
}
|
|
2103
|
+
else if (method === "complete" && result.status === "authenticated") {
|
|
2104
|
+
into.violations.push(...validateAuthenticationState(result, path, context));
|
|
2105
|
+
}
|
|
2106
|
+
else {
|
|
2107
|
+
into.add("authentication.result.status", `${path}.status`, `${method} answers ${method === "start" ? "interaction-required" : "authenticated"} or rejected, not ${String(result.status)}`);
|
|
2108
|
+
}
|
|
2109
|
+
return into.violations;
|
|
2110
|
+
}
|
|
2056
2111
|
export function validateAuthenticationState(state, path = "authentication", context = {}) {
|
|
2057
2112
|
const into = new Collector();
|
|
2058
2113
|
if (!isPlainObject(state)) {
|
|
@@ -2072,14 +2127,7 @@ export function validateAuthenticationState(state, path = "authentication", cont
|
|
|
2072
2127
|
if (state.identity !== undefined)
|
|
2073
2128
|
validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
|
|
2074
2129
|
if (state.failure !== undefined) {
|
|
2075
|
-
|
|
2076
|
-
into.add("authentication.failure.shape", `${path}.failure`, "a failure must be an object when present");
|
|
2077
|
-
}
|
|
2078
|
-
else {
|
|
2079
|
-
into.filled(state.failure.code, "authentication.failure.code", `${path}.failure.code`, "a failure needs a code");
|
|
2080
|
-
into.filled(state.failure.message, "authentication.failure.message", `${path}.failure.message`, "a failure needs a message");
|
|
2081
|
-
into.require(typeof state.failure.retryable === "boolean", "authentication.failure.retryable", `${path}.failure.retryable`, "a failure must say whether it is retryable");
|
|
2082
|
-
}
|
|
2130
|
+
validateAuthenticationFailureInto(state.failure, `${path}.failure`, into);
|
|
2083
2131
|
}
|
|
2084
2132
|
}
|
|
2085
2133
|
else {
|
package/guide.md
CHANGED
|
@@ -1120,6 +1120,7 @@ const OMNI_FAILURE_CODES = [
|
|
|
1120
1120
|
"omni.capability-not-enabled",
|
|
1121
1121
|
"omni.task-not-found",
|
|
1122
1122
|
"omni.destination-not-permitted",
|
|
1123
|
+
"omni.phone-not-permitted",
|
|
1123
1124
|
"omni.rate-limited",
|
|
1124
1125
|
"omni.unavailable",
|
|
1125
1126
|
"omni.break-already-committed",
|
|
@@ -3799,6 +3800,21 @@ that is a telephone. The harness requires no `openMedia` of such an adapter and
|
|
|
3799
3800
|
this wire never asks the agent for it: a host declares `phone` and nothing more. What a platform
|
|
3800
3801
|
asks on surfaces of its own is its own decision.
|
|
3801
3802
|
|
|
3803
|
+
**A declared phone the platform does not permit for this agent is a login that cannot be
|
|
3804
|
+
established.** The manifest says what the platform can do; the agent's record, kept by an
|
|
3805
|
+
administrator, says what this agent is configured for, and the two can disagree with the host's
|
|
3806
|
+
declaration -- a softphone declared for an agent whose record says desk phone. Honouring that
|
|
3807
|
+
declaration on a switch that allows one registration per endpoint would evict the handset: the
|
|
3808
|
+
agent's phone stops ringing and their calls land in a tab, and nobody is told. So the provider
|
|
3809
|
+
refuses the login at authentication, with `omni.phone-not-permitted` and a message the host shows
|
|
3810
|
+
-- "this agent is configured for a desk phone" -- and refusing is correct, not an override of the
|
|
3811
|
+
host. It is a refusal, never a negotiation afterwards and never a quiet substitution, and it is
|
|
3812
|
+
never retryable: trying again does not reconfigure the agent, and a host validating the answer
|
|
3813
|
+
(`validateAuthenticationResult`) refuses a phone refusal marked otherwise
|
|
3814
|
+
(`authentication.failure.phone.retryable`). And **a
|
|
3815
|
+
provider never makes a declared phone true by changing the platform's configuration**: the record
|
|
3816
|
+
is the administrator's, and a login is not a request to reconfigure an agent.
|
|
3817
|
+
|
|
3802
3818
|
### Opening the audio
|
|
3803
3819
|
|
|
3804
3820
|
`openMedia` hands Omni the remote audio for one task. Every adapter whose manifest lists
|
|
@@ -3963,6 +3979,7 @@ react rather than only display the message:
|
|
|
3963
3979
|
| `omni.not-authenticated` | The provider session is no longer usable. The adapter has published `expired` at or before this answer — the state is what Omni surfaces reauthentication from; the code says why this action failed, and is never the only signal. |
|
|
3964
3980
|
| `omni.capability-not-enabled` | The action targets a capability this task, manifest, or login did not declare — including a lead command from a login whose `capabilities` no longer carry it. |
|
|
3965
3981
|
| `omni.task-not-found` | The provider-local task id is unknown, typically after the task already ended. |
|
|
3982
|
+
| `omni.phone-not-permitted` | The host declared a `phone` the platform does not permit for this agent -- a softphone for an agent configured for a desk phone, or the reverse. The login is refused at authentication, and the provider never reconfigures the agent to make the declaration true. See **How the agent hears the call**. |
|
|
3966
3983
|
| `omni.destination-not-permitted` | The dialled number, or the `destinationId` named, is not one the provider offers this agent. |
|
|
3967
3984
|
| `omni.rate-limited` | The action was throttled. Pair with `retryAfterMs`. |
|
|
3968
3985
|
| `omni.unavailable` | The provider is temporarily unable to serve the action, including any command sent while `transport-status` is not `active`. |
|
|
@@ -4127,6 +4144,9 @@ partial patch.
|
|
|
4127
4144
|
The provider's word that the task's audio should now attach. Omni calls `openMedia` on it — and on
|
|
4128
4145
|
a task carried with `media: "started"`, which is how a reconnect snapshot reattaches audio an
|
|
4129
4146
|
earlier event brought — and renders the call as live from that word, never from its own senses. It
|
|
4147
|
+
precedes `openMedia` and is never a reply to it: a provider whose media state comes from the
|
|
4148
|
+
platform, a station going in use the moment a call is answered, sends it then, before any host has
|
|
4149
|
+
opened anything, and `openMedia` has its own answer for what the host did. It
|
|
4130
4150
|
names a task whose work has begun, and it alternates with `task-media-ended`: media that never
|
|
4131
4151
|
started cannot end, so a live call whose provider says nothing about its audio is a provider in
|
|
4132
4152
|
breach, not a state a desk fills in from its own devices.
|
|
@@ -4267,6 +4287,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
4267
4287
|
| `validateHostGuarantees(guarantees)` | What a host promises: only the guarantees this contract names, each declared by presence and never `false`. The harness validates the guarantees of whatever host a test hands the adapter. |
|
|
4268
4288
|
| `validateHandlingReport(report, path?, manifest?)` | What the host reports of a leg it performed, for an adapter to check before forwarding: a task, a step, when it began, a positive `seconds` where stated, and an explicit `ended` that carries the final duration. Given the manifest, a running report is refused unless it declares `runningStepReports`. |
|
|
4269
4289
|
| `validateHostReport(report)` | The host's own report as published to an adapter: `online`, and where there is audio, an input that is `available` with the microphone and `flowing`, or `unavailable` with a reason and the failure that says why, and an output that is `available` or `unavailable` with its failure. The harness validates whatever host a test hands the adapter; `stillHost(report)` builds one that never changes. |
|
|
4290
|
+
| `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. |
|
|
4270
4291
|
| `validateTaskCommand(command, task?)` | What a command needs to be issuable, against the task it names: its own shape -- a dial's `dialId`, a transfer's item, 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.*`), and the state that has to stand: a consulted entry, a lead requested, somebody else still on the call (`command.conference.remove.alone`). A host validates before sending and an adapter before acting. |
|
|
4271
4292
|
| `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. |
|
|
4272
4293
|
| `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. |
|
|
@@ -4338,8 +4359,14 @@ complete it with a disposition where the agent completes -- and holds every step
|
|
|
4338
4359
|
host holds a provider to. Each command is validated against the task as published
|
|
4339
4360
|
(`drive.command.*`), each answer for its method, a refusal of a control the task offered is a
|
|
4340
4361
|
violation (`drive.command.failed`), and an event the provider owes and never sends is one too
|
|
4341
|
-
(`drive.timeout`, after `driveTimeoutMs`, 5000 by default).
|
|
4342
|
-
|
|
4362
|
+
(`drive.timeout`, after `driveTimeoutMs`, 5000 by default). A task the agent completes is
|
|
4363
|
+
completed from wherever it stands once the drive has nothing left to do on it -- from `completing`
|
|
4364
|
+
after an `end-call`, or from `in-progress` where there is no call to end, which is every chat and
|
|
4365
|
+
email and a voice task offering no `endCall` -- so a conversation reaches its end as a call does.
|
|
4366
|
+
The drive stops where the task offers no way on and says nothing about what it could not reach. A
|
|
4367
|
+
softphone adapter written for a browser needs its media APIs supplied by whatever runs the
|
|
4368
|
+
harness: the drive opens media outside a browser, and a provider that quietly stopped carrying audio
|
|
4369
|
+
where `AudioContext` was missing would be lying about the one thing the channel is for. It is off by default because it issues
|
|
4343
4370
|
commands against whatever platform the adapter is connected to: turn it on against a test backend.
|
|
4344
4371
|
|
|
4345
4372
|
```ts
|
|
@@ -4349,6 +4376,10 @@ assertReached(driven, ["task.onCall", "task.media", "event.task-ended"]);
|
|
|
4349
4376
|
```
|
|
4350
4377
|
`assertReached(result, subjects)` is the paired assertion: it throws naming every subject the run
|
|
4351
4378
|
never met, so a test that meant to check a roster cannot pass on a fixture that never produced one.
|
|
4379
|
+
It reads like a guarantee and is a claim the adopter keeps making: it catches an adapter that
|
|
4380
|
+
stopped reaching a subject, not a list that stopped asking, so a list can rot to nothing and stay
|
|
4381
|
+
green. Keep it honest with a control beside it -- one subject the run genuinely cannot reach,
|
|
4382
|
+
asserted to throw -- so the assertion is shown to be looking rather than agreeing.
|
|
4352
4383
|
|
|
4353
4384
|
Three properties of the harness matter to adapter authors:
|
|
4354
4385
|
|