@xema/omni-protocol 0.1.5 → 0.1.7
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/README.md +1 -1
- package/dist/index.d.ts +16 -32
- package/dist/testing.d.ts +1 -5
- package/dist/testing.js +0 -33
- package/dist/validation.js +13 -4
- package/guide.md +85 -104
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ expect(result.disconnectWasClean).toBe(true);
|
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
Run the contract scenarios beside it — authentication restore and expiry, reconnect with missed
|
|
56
|
-
assignments, break denial and retry,
|
|
56
|
+
assignments, break denial and retry, wrap timeout, browser isolation.
|
|
57
57
|
|
|
58
58
|
> **Assert both directions.** Every helper rejects a violating input as well as accepting a
|
|
59
59
|
> conforming one. A suite that only asserts "this conforming case does not throw" passes unchanged
|
package/dist/index.d.ts
CHANGED
|
@@ -579,33 +579,29 @@ export interface CustomTaskCommand {
|
|
|
579
579
|
}
|
|
580
580
|
export type TaskCommand<C extends Channel = Channel> = (C extends "voice" ? VoiceTaskCommand : C extends "chat" ? ChatTaskCommand : EmailTaskCommand) | CustomTaskCommand;
|
|
581
581
|
export interface TaskCommandRequest<C extends Channel = Channel> {
|
|
582
|
-
/** Stable across retries. Processing one twice must not repeat its side effects. */
|
|
583
|
-
commandId: string;
|
|
584
582
|
taskId: TaskId;
|
|
585
583
|
command: TaskCommand<C>;
|
|
586
584
|
}
|
|
587
585
|
/**
|
|
588
|
-
* `applied`
|
|
589
|
-
*
|
|
590
|
-
*
|
|
586
|
+
* `applied` rather than a verb per command: the command travels in the request, so
|
|
587
|
+
* `execute({ command: { type: "hold" } })` returning `applied` already says the hold applied.
|
|
588
|
+
*
|
|
589
|
+
* A promise that rejects with no result means *unknown*, not `failed`. Omni does not retry --
|
|
590
|
+
* no adapter on the agent's PC can make a repeat safe -- and the next snapshot shows what the
|
|
591
|
+
* provider did. A command therefore carries no key of Omni's making.
|
|
591
592
|
*/
|
|
592
593
|
export type TaskCommandResult = {
|
|
593
|
-
|
|
594
|
-
status: "applied" | "already-applied";
|
|
594
|
+
status: "applied";
|
|
595
595
|
} | {
|
|
596
|
-
commandId: string;
|
|
597
596
|
status: "failed";
|
|
598
597
|
failure: ProtocolFailure;
|
|
599
598
|
};
|
|
600
599
|
export interface DialRequest {
|
|
601
|
-
commandId: string;
|
|
602
600
|
destination: string;
|
|
603
601
|
}
|
|
604
602
|
export type DialResult = {
|
|
605
|
-
|
|
606
|
-
status: "dialled" | "already-dialled";
|
|
603
|
+
status: "dialled";
|
|
607
604
|
} | {
|
|
608
|
-
commandId: string;
|
|
609
605
|
status: "failed";
|
|
610
606
|
failure: ProtocolFailure;
|
|
611
607
|
};
|
|
@@ -635,8 +631,6 @@ export interface BreakReason {
|
|
|
635
631
|
alwaysAvailable?: true;
|
|
636
632
|
}
|
|
637
633
|
export interface BreakRequest {
|
|
638
|
-
/** Stable across retries, and what makes `already-requested` recognisable. */
|
|
639
|
-
requestId: string;
|
|
640
634
|
reason?: string;
|
|
641
635
|
/** The chosen `BreakReason.id`, where the provider publishes codes. */
|
|
642
636
|
reasonId?: string;
|
|
@@ -659,7 +653,6 @@ export type ImposedBreak = {
|
|
|
659
653
|
};
|
|
660
654
|
export interface BreakState {
|
|
661
655
|
approval: BreakApproval;
|
|
662
|
-
requestId?: string;
|
|
663
656
|
/** Whether the agent may ask at all. Distinct from the fate of a request already made. */
|
|
664
657
|
accepting: boolean;
|
|
665
658
|
/** Shown when `accepting` is false, such as "Busy hours". */
|
|
@@ -680,31 +673,26 @@ export type CapacityResult = {
|
|
|
680
673
|
};
|
|
681
674
|
/** Succeeding is not the outcome: `requested` says the provider holds it, not that it was granted. */
|
|
682
675
|
export type BreakRequestResult = {
|
|
683
|
-
|
|
684
|
-
status: "requested" | "already-requested";
|
|
676
|
+
status: "requested";
|
|
685
677
|
} | {
|
|
686
|
-
requestId: string;
|
|
687
678
|
status: "failed";
|
|
688
679
|
failure: ProtocolFailure;
|
|
689
680
|
};
|
|
681
|
+
/** Committing a break already in effect changes nothing and answers `committed`. */
|
|
690
682
|
export type BreakCommitResult = {
|
|
691
|
-
|
|
692
|
-
status: "committed" | "already-committed";
|
|
683
|
+
status: "committed";
|
|
693
684
|
} | {
|
|
694
|
-
requestId: string;
|
|
695
685
|
status: "failed";
|
|
696
686
|
failure: ProtocolFailure;
|
|
697
687
|
};
|
|
698
688
|
export type BreakCancelResult = {
|
|
699
|
-
|
|
700
|
-
status: "cancelled" | "already-cancelled";
|
|
689
|
+
status: "cancelled";
|
|
701
690
|
} | {
|
|
702
|
-
requestId: string;
|
|
703
691
|
status: "failed";
|
|
704
692
|
failure: ProtocolFailure;
|
|
705
693
|
};
|
|
706
694
|
export type BreakEndResult = {
|
|
707
|
-
status: "ended"
|
|
695
|
+
status: "ended";
|
|
708
696
|
} | {
|
|
709
697
|
status: "failed";
|
|
710
698
|
failure: ProtocolFailure;
|
|
@@ -746,7 +734,6 @@ export type TeamConsultCommand = {
|
|
|
746
734
|
reason?: string;
|
|
747
735
|
};
|
|
748
736
|
export interface TeamConsultCommandRequest {
|
|
749
|
-
commandId: string;
|
|
750
737
|
command: TeamConsultCommand;
|
|
751
738
|
}
|
|
752
739
|
export type TeamBreakCommand = {
|
|
@@ -766,15 +753,12 @@ export type TeamBreakCommand = {
|
|
|
766
753
|
memberId: UserId;
|
|
767
754
|
};
|
|
768
755
|
export type TeamCommandResult = {
|
|
769
|
-
|
|
770
|
-
status: "applied" | "already-applied";
|
|
756
|
+
status: "applied";
|
|
771
757
|
} | {
|
|
772
|
-
commandId: string;
|
|
773
758
|
status: "failed";
|
|
774
759
|
failure: ProtocolFailure;
|
|
775
760
|
};
|
|
776
761
|
export interface TeamBreakCommandRequest {
|
|
777
|
-
commandId: string;
|
|
778
762
|
command: TeamBreakCommand;
|
|
779
763
|
}
|
|
780
764
|
export interface VoiceMediaSession {
|
|
@@ -909,8 +893,8 @@ export interface Connection<C extends Channel = Channel> {
|
|
|
909
893
|
* never start, and the two-phase coordination has no way to report it.
|
|
910
894
|
*/
|
|
911
895
|
requestBreak?(request: BreakRequest): Promise<BreakRequestResult>;
|
|
912
|
-
commitBreak?(
|
|
913
|
-
cancelBreak?(
|
|
896
|
+
commitBreak?(): Promise<BreakCommitResult>;
|
|
897
|
+
cancelBreak?(): Promise<BreakCancelResult>;
|
|
914
898
|
endBreak?(): Promise<BreakEndResult>;
|
|
915
899
|
/** Required when the adapter publishes a `TeamRoster` carrying `breakControl`. */
|
|
916
900
|
executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type
|
|
1
|
+
import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext } from "./index.js";
|
|
2
2
|
import { type ProtocolViolation } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation } from "./validation.js";
|
|
4
4
|
export interface AdapterContractResult {
|
|
@@ -25,8 +25,6 @@ export interface ExerciseAdapterOptions {
|
|
|
25
25
|
* asynchronous one — which would let a non-conforming async adapter pass.
|
|
26
26
|
*/
|
|
27
27
|
export declare function exerciseAdapter<C extends Channel>(adapter: Adapter<C>, context: ConnectContext, options?: ExerciseAdapterOptions): Promise<AdapterContractResult>;
|
|
28
|
-
/** Verifies the at-most-once contract by issuing the same command twice. */
|
|
29
|
-
export declare function assertCommandIdempotency(connection: Pick<Connection, "execute">, request: TaskCommandRequest): Promise<void>;
|
|
30
28
|
/** Validates restored authentication followed by a refresh failure or expiry. */
|
|
31
29
|
export declare function assertAuthenticationRestoreAndExpiry(states: readonly AuthenticationState[]): void;
|
|
32
30
|
/** Validates duplicate delivery and returns the event sequence Omni applies once per ID. */
|
|
@@ -45,8 +43,6 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
|
|
|
45
43
|
* both.
|
|
46
44
|
*/
|
|
47
45
|
export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
|
|
48
|
-
/** Verifies that retrying one dial command cannot place a second call. */
|
|
49
|
-
export declare function assertDialIdempotency(connection: Pick<Connection, "dial">, request: DialRequest): Promise<void>;
|
|
50
46
|
/**
|
|
51
47
|
* Validates the deadline derived from media end and the task's fixed wrap allowance.
|
|
52
48
|
*
|
package/dist/testing.js
CHANGED
|
@@ -143,21 +143,6 @@ function publishesUserIds(snapshot) {
|
|
|
143
143
|
return false;
|
|
144
144
|
return snapshot.tasks.some(task => Array.isArray(task?.handlingHistory) && task.handlingHistory.some(step => step?.by !== undefined));
|
|
145
145
|
}
|
|
146
|
-
/** Verifies the at-most-once contract by issuing the same command twice. */
|
|
147
|
-
export async function assertCommandIdempotency(connection, request) {
|
|
148
|
-
const first = await connection.execute(request);
|
|
149
|
-
if (first.commandId !== request.commandId)
|
|
150
|
-
throw new Error("Command result id mismatch");
|
|
151
|
-
if (first.status === "failed") {
|
|
152
|
-
throw new Error(`Command failed: ${first.failure.code}`);
|
|
153
|
-
}
|
|
154
|
-
const retry = await connection.execute(request);
|
|
155
|
-
if (retry.commandId !== request.commandId)
|
|
156
|
-
throw new Error("Retried command result id mismatch");
|
|
157
|
-
if (retry.status !== "already-applied") {
|
|
158
|
-
throw new Error(`Retried command must return already-applied, received ${retry.status}`);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
146
|
/** Validates restored authentication followed by a refresh failure or expiry. */
|
|
162
147
|
export function assertAuthenticationRestoreAndExpiry(states) {
|
|
163
148
|
if (states.length < 2 || states[0]?.status !== "authenticated") {
|
|
@@ -231,24 +216,6 @@ export function assertDeniedAndRetriedBreak(approvals) {
|
|
|
231
216
|
throw new Error(`Break retry scenario must end granted or in effect, ended ${String(last)}`);
|
|
232
217
|
}
|
|
233
218
|
}
|
|
234
|
-
/** Verifies that retrying one dial command cannot place a second call. */
|
|
235
|
-
export async function assertDialIdempotency(connection, request) {
|
|
236
|
-
if (!connection.dial)
|
|
237
|
-
throw new Error("Dial capability requires Connection.dial()");
|
|
238
|
-
const first = await connection.dial(request);
|
|
239
|
-
if (first.commandId !== request.commandId)
|
|
240
|
-
throw new Error("Dial result id mismatch");
|
|
241
|
-
if (first.status === "failed")
|
|
242
|
-
throw new Error(`Dial failed: ${first.failure.code}`);
|
|
243
|
-
const retry = await connection.dial(request);
|
|
244
|
-
if (retry.commandId !== request.commandId)
|
|
245
|
-
throw new Error("Retried dial result id mismatch");
|
|
246
|
-
// Each method answers in its own words: a retried dial says already-dialled, not
|
|
247
|
-
// already-applied, because what it did was dial.
|
|
248
|
-
if (retry.status !== "already-dialled") {
|
|
249
|
-
throw new Error(`Retried dial must return already-dialled, received ${retry.status}`);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
219
|
/**
|
|
253
220
|
* Validates the deadline derived from media end and the task's fixed wrap allowance.
|
|
254
221
|
*
|
package/dist/validation.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Each list is pinned to its type both ways -- a member the type lacks, or a member the list
|
|
10
10
|
// lacks, fails to compile -- so what the validators accept cannot drift from what the
|
|
11
11
|
// declarations say.
|
|
12
|
-
import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, } from "./index.js";
|
|
12
|
+
import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, negotiateProtocolVersion, } from "./index.js";
|
|
13
13
|
export class ProtocolConformanceError extends Error {
|
|
14
14
|
violations;
|
|
15
15
|
constructor(violations, summary = "Adapter violates the Omni protocol") {
|
|
@@ -271,6 +271,10 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
271
271
|
versions.forEach((version, index) => {
|
|
272
272
|
into.require(typeof version === "number" && Number.isInteger(version) && version > 0, "manifest.supportedProtocolVersions.value", `${path}.supportedProtocolVersions[${index}]`, "a protocol version must be a positive integer");
|
|
273
273
|
});
|
|
274
|
+
// Interoperability: the adapter must speak a version this package does, or Omni must refuse
|
|
275
|
+
// to connect. Reported here so it is found with the manifest rather than at connect time.
|
|
276
|
+
const declared = versions.filter((version) => typeof version === "number");
|
|
277
|
+
into.require(negotiateProtocolVersion(declared) !== undefined, "manifest.supportedProtocolVersions.interoperable", `${path}.supportedProtocolVersions`, `this host speaks protocol version${OMNI_SUPPORTED_PROTOCOL_VERSIONS.length === 1 ? "" : "s"} ${OMNI_SUPPORTED_PROTOCOL_VERSIONS.join(", ")}; the adapter declares none of them`);
|
|
274
278
|
}
|
|
275
279
|
const methods = manifest.authenticationMethods;
|
|
276
280
|
if (!Array.isArray(methods) || methods.length === 0) {
|
|
@@ -434,9 +438,14 @@ function validateBrowsers(value, path, into) {
|
|
|
434
438
|
}
|
|
435
439
|
// Reuse and its scheme travel together. A reusing browser with no scheme would otherwise
|
|
436
440
|
// inherit whatever a host happened to default to, which is how two tasks end up sharing a
|
|
437
|
-
// session nobody intended.
|
|
441
|
+
// session nobody intended. The guide names the rule for the missing case.
|
|
438
442
|
if (browser.reuse === true) {
|
|
439
|
-
|
|
443
|
+
if (browser.isolationScheme === undefined) {
|
|
444
|
+
into.add("task.browser.isolationScheme.required", `${at}.isolationScheme`, `a reusing browser must declare one of: ${ISOLATION_SCHEME_VALUES.join(", ")}`);
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
into.require(ISOLATION_SCHEME_VALUES.includes(browser.isolationScheme), "task.browser.isolationScheme", `${at}.isolationScheme`, `an isolation scheme must be one of: ${ISOLATION_SCHEME_VALUES.join(", ")}`);
|
|
448
|
+
}
|
|
440
449
|
}
|
|
441
450
|
else if (browser.reuse === false) {
|
|
442
451
|
into.require(browser.isolationScheme === undefined, "task.browser.isolationScheme.unexpected", `${at}.isolationScheme`, "a browser that does not reuse must not declare an isolation scheme");
|
|
@@ -654,7 +663,7 @@ function validateBreakState(value, path, into) {
|
|
|
654
663
|
}
|
|
655
664
|
into.oneOf(value.approval, BREAK_APPROVALS, "break.approval", `${path}.approval`);
|
|
656
665
|
into.require(typeof value.accepting === "boolean", "break.accepting", `${path}.accepting`, "accepting must be a boolean");
|
|
657
|
-
for (const field of ["
|
|
666
|
+
for (const field of ["refusedReason", "decisionReason"]) {
|
|
658
667
|
if (value[field] !== undefined) {
|
|
659
668
|
into.filled(value[field], `break.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
|
|
660
669
|
}
|
package/guide.md
CHANGED
|
@@ -542,7 +542,6 @@ type TaskCommand<C extends Channel = Channel> =
|
|
|
542
542
|
| CustomTaskCommand;
|
|
543
543
|
|
|
544
544
|
type TaskCommandRequest<C extends Channel = Channel> = {
|
|
545
|
-
commandId: string;
|
|
546
545
|
taskId: TaskId;
|
|
547
546
|
command: TaskCommand<C>;
|
|
548
547
|
};
|
|
@@ -567,7 +566,6 @@ type BreakReason = {
|
|
|
567
566
|
};
|
|
568
567
|
|
|
569
568
|
type BreakRequest = {
|
|
570
|
-
requestId: string;
|
|
571
569
|
reason?: string;
|
|
572
570
|
reasonId?: string;
|
|
573
571
|
};
|
|
@@ -578,7 +576,6 @@ type ImposedBreak =
|
|
|
578
576
|
|
|
579
577
|
type BreakState = {
|
|
580
578
|
approval: BreakApproval;
|
|
581
|
-
requestId?: string;
|
|
582
579
|
accepting: boolean;
|
|
583
580
|
refusedReason?: string;
|
|
584
581
|
decisionReason?: string;
|
|
@@ -915,18 +912,27 @@ Events report completed transactions after that baseline. Nothing is missed whil
|
|
|
915
912
|
holds; when it drops, the reconnect snapshot re-establishes the baseline before any further event
|
|
916
913
|
is applied.
|
|
917
914
|
|
|
918
|
-
### 6.
|
|
915
|
+
### 6. An unsettled result is unknown, and unknown is not retried
|
|
919
916
|
|
|
920
|
-
|
|
921
|
-
|
|
917
|
+
A settled result is a fact. A promise that rejects with no result — the transport died — means
|
|
918
|
+
*unknown*: the provider may have done it or not, and neither Omni nor an adapter on the agent's PC
|
|
919
|
+
can find out in time to make a repeat safe. Omni does not retry. The next snapshot shows what the
|
|
920
|
+
provider actually did, and if the agent acts again it is a new command.
|
|
922
921
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
`already-committed`, and so on. Each answers in its own words; see **Capacity and break actions**.
|
|
922
|
+
A command therefore carries no key. The provider names its own records — a task, a lead request, a
|
|
923
|
+
member — and Omni refers to them by those names; **Omni never asks a provider to remember a name
|
|
924
|
+
Omni made up.**
|
|
927
925
|
|
|
928
|
-
|
|
929
|
-
|
|
926
|
+
A command whose second execution changes nothing needs no protection: committing a break that is
|
|
927
|
+
already committed stops an agent who is already stopped. A command whose second execution has a
|
|
928
|
+
cost — a dial places a second call — is not made safe by anyone, which is why it is never repeated
|
|
929
|
+
without a person deciding to.
|
|
930
|
+
|
|
931
|
+
An agent dials. The provider places the call and the answer is lost. Omni shows the dial as
|
|
932
|
+
unknown, not failed. Within a moment the provider offers the resulting call through `task-offered`
|
|
933
|
+
and the agent is on it; had nothing been placed, nothing arrives and the agent dials again. What
|
|
934
|
+
Omni must not do is dial again on the agent's behalf — the one outcome worse than a lost answer is
|
|
935
|
+
two phones ringing at the customer.
|
|
930
936
|
|
|
931
937
|
### 7. Work is pulled, never pushed
|
|
932
938
|
|
|
@@ -1394,7 +1400,7 @@ authorization codes, tokens, or provider responses containing secrets.
|
|
|
1394
1400
|
### Sign-out
|
|
1395
1401
|
|
|
1396
1402
|
`signOut(requestId)` revokes or invalidates the provider session where supported, deletes stored
|
|
1397
|
-
session secrets, and moves state to `signed-out`.
|
|
1403
|
+
session secrets, and moves state to `signed-out`.
|
|
1398
1404
|
`close()` stops authentication-state observation but does not sign the agent out.
|
|
1399
1405
|
|
|
1400
1406
|
### Secure-storage boundary
|
|
@@ -1471,8 +1477,8 @@ surface in one place, and what obliges an adapter to implement each one.
|
|
|
1471
1477
|
| `describeUsers(ids)` | The adapter publishes any `UserId`: on `ImposedBreak.by`, a roster, or `handlingHistory[].by`. |
|
|
1472
1478
|
| `dial(request)` | The manifest declares `idleCapabilities.dial`. |
|
|
1473
1479
|
| `requestBreak(request)` | `sessionCapabilities.breaks` is declared. |
|
|
1474
|
-
| `commitBreak(
|
|
1475
|
-
| `cancelBreak(
|
|
1480
|
+
| `commitBreak()` | `sessionCapabilities.breaks` is declared. Commit and cancel are not optional halves of it. |
|
|
1481
|
+
| `cancelBreak()` | `sessionCapabilities.breaks` is declared. |
|
|
1476
1482
|
| `endBreak()` | `sessionCapabilities.breaks` is declared. |
|
|
1477
1483
|
| `executeTeamBreak(command)` | The adapter publishes a `TeamRoster` carrying `breakControl`. |
|
|
1478
1484
|
| `executeTeamConsult(command)` | The adapter publishes a `TeamRoster` carrying `consultControl`. |
|
|
@@ -2158,11 +2164,9 @@ Custom capabilities must not redefine the meaning of a standard channel capabili
|
|
|
2158
2164
|
Starts one outbound call from the idle dialpad. It is present only when the voice provider
|
|
2159
2165
|
declares `dial`.
|
|
2160
2166
|
|
|
2161
|
-
- `commandId` remains stable across retries; the provider must place at most one call for it.
|
|
2162
2167
|
- `destination` is the original number selected or entered by the agent.
|
|
2163
2168
|
- `source` is `contact` or `manual` and must comply with `destinationPolicy`.
|
|
2164
2169
|
- `dialled` confirms that outbound call creation completed.
|
|
2165
|
-
- `already-dialled` confirms a retry that placed no second call.
|
|
2166
2170
|
- `failed` contains a `ProtocolFailure` and confirms no call was placed.
|
|
2167
2171
|
|
|
2168
2172
|
The resulting call is offered through the normal `task-offered` event. A successful dial result
|
|
@@ -2178,7 +2182,6 @@ nothing while none are being accepted — so they are not published separately.
|
|
|
2178
2182
|
| Field | Contract |
|
|
2179
2183
|
| --- | --- |
|
|
2180
2184
|
| `approval` | Where the agent's current request stands. See the states below. |
|
|
2181
|
-
| `requestId` | Correlates an agent-requested break while approval is `awaiting-decision`, `granted`, `starting-after-task`, or `in-effect`. Omitted for imposed breaks and when no request is active. |
|
|
2182
2185
|
| `accepting` | Whether the agent may ask at all. Distinct from `approval`. |
|
|
2183
2186
|
| `refusedReason` | Display-ready reason shown when `accepting` is false — a standing gate that applies to everyone. |
|
|
2184
2187
|
| `decisionReason` | The words whoever decided attached, from `decide.reason`. About one request and one decision, not a standing gate. |
|
|
@@ -2252,13 +2255,13 @@ branched on by code — in a log, a support ticket, a conformance failure — so
|
|
|
2252
2255
|
rather than that something happened. `failed` is shared, because failing is the same act
|
|
2253
2256
|
everywhere; success is not.
|
|
2254
2257
|
|
|
2255
|
-
| Method | Succeeded |
|
|
2256
|
-
| --- | --- |
|
|
2257
|
-
| `setCapacity` | `accepted` |
|
|
2258
|
-
| `requestBreak` | `requested` |
|
|
2259
|
-
| `commitBreak` | `committed` |
|
|
2260
|
-
| `cancelBreak` | `cancelled` |
|
|
2261
|
-
| `endBreak` | `ended` |
|
|
2258
|
+
| Method | Succeeded |
|
|
2259
|
+
| --- | --- |
|
|
2260
|
+
| `setCapacity` | `accepted` |
|
|
2261
|
+
| `requestBreak` | `requested` |
|
|
2262
|
+
| `commitBreak` | `committed` |
|
|
2263
|
+
| `cancelBreak` | `cancelled` |
|
|
2264
|
+
| `endBreak` | `ended` |
|
|
2262
2265
|
|
|
2263
2266
|
`failed` carries a typed `ProtocolFailure` and means the provider did not take the action, whether
|
|
2264
2267
|
it would not or could not.
|
|
@@ -2269,15 +2272,7 @@ again.
|
|
|
2269
2272
|
Every break method reports its real result through `break-state`; `setCapacity` reports none at
|
|
2270
2273
|
all, because capacity is a statement rather than a request.
|
|
2271
2274
|
|
|
2272
|
-
`
|
|
2273
|
-
accumulates, and re-sending the current one changes nothing. The four break methods carry a
|
|
2274
|
-
`requestId`
|
|
2275
|
-
precisely so a retry can be recognised, and `already-committed` is the one commit recovery lives
|
|
2276
|
-
on — retrying `commitBreak` into a partially delivered attempt, it is the difference between *I
|
|
2277
|
-
have committed now* and *I committed before you asked*, which is how Omni knows the attempt has
|
|
2278
|
-
converged rather than only that a message arrived.
|
|
2279
|
-
|
|
2280
|
-
`execute` keeps `applied` and `already-applied` rather than a verb per command, because the command
|
|
2275
|
+
`execute` keeps `applied` rather than a verb per command, because the command
|
|
2281
2276
|
is in the request: `execute({ command: { type: "hold" } })` returning `applied` already says the
|
|
2282
2277
|
hold applied. A `held` result would repeat the discriminant that travelled with it.
|
|
2283
2278
|
|
|
@@ -2297,8 +2292,7 @@ go, and a provider that waits for it will stall.
|
|
|
2297
2292
|
Your own tasks are the only ones you count. What the agent holds at other providers is not your
|
|
2298
2293
|
concern — Omni set `count` knowing it.
|
|
2299
2294
|
|
|
2300
|
-
Capacity supersedes rather than accumulates
|
|
2301
|
-
the latest value is the ceiling.
|
|
2295
|
+
Capacity supersedes rather than accumulates: the latest value is the ceiling.
|
|
2302
2296
|
|
|
2303
2297
|
**Capacity gates what the provider allocates, not what the agent starts.** A call placed from the
|
|
2304
2298
|
idle dialpad arrives through `task-offered` like any other task, and a full agent does not forbid
|
|
@@ -2306,9 +2300,8 @@ it: the ceiling binds allocation, not the agent's own hand.
|
|
|
2306
2300
|
|
|
2307
2301
|
### `requestBreak(request)`
|
|
2308
2302
|
|
|
2309
|
-
Requests permission to stop the agent later; it does not itself stop work.
|
|
2310
|
-
|
|
2311
|
-
`awaiting-decision` or `granted` through `break-state` events. If the request is denied, the
|
|
2303
|
+
Requests permission to stop the agent later; it does not itself stop work. The provider continues
|
|
2304
|
+
offering work and reports `awaiting-decision` or `granted` through `break-state` events. If the request is denied, the
|
|
2312
2305
|
provider reports `not-requested` directly, with `decisionReason` when one was supplied.
|
|
2313
2306
|
|
|
2314
2307
|
#### Break reasons
|
|
@@ -2427,26 +2420,25 @@ Omni coordinates one attempt as follows:
|
|
|
2427
2420
|
1. Freeze the participant set to every connected provider from which the agent can currently
|
|
2428
2421
|
receive work. A provider joining during the attempt is given no capacity until it finishes.
|
|
2429
2422
|
2. Enter `requesting-break`. Keep the agent's normal capacity in place throughout this phase.
|
|
2430
|
-
3. Send one `requestBreak` to every participant
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
`not-requested` and causes Omni to take the cancel path.
|
|
2423
|
+
3. Send one `requestBreak` to every participant. A provider reports `awaiting-decision` or
|
|
2424
|
+
`granted`; neither state stops work. A denial transitions directly to `not-requested` and
|
|
2425
|
+
causes Omni to take the cancel path.
|
|
2434
2426
|
4. If every participant reports `granted`, durably choose commit, enter `committing-break`,
|
|
2435
|
-
and send `commitBreak(
|
|
2427
|
+
and send `commitBreak()` to every participant. A provider then stops offering new work
|
|
2436
2428
|
and reports `starting-after-task` or `in-effect`. Omni enters `on-break` once every participant
|
|
2437
2429
|
it can still reach reports `in-effect`, and no later than the **commit bound** — ten seconds
|
|
2438
2430
|
from the decision, tunable per deployment. A participant that has not applied the commit by then
|
|
2439
2431
|
is set aside as unreconciled; the break begins without it.
|
|
2440
2432
|
5. If any participant fails or denies the request, cannot be reconciled within the bounded
|
|
2441
2433
|
decision timeout, or the agent cancels before commit, durably choose cancel and enter
|
|
2442
|
-
`cancelling-break`. Send `cancelBreak(
|
|
2434
|
+
`cancelling-break`. Send `cancelBreak()` to every participant still reporting
|
|
2443
2435
|
`awaiting-decision` or `granted`. Work continues during cancellation because no stop was
|
|
2444
2436
|
committed. Return to `working` only after no participant retains either state.
|
|
2445
2437
|
|
|
2446
2438
|
Commit and cancel are mutually exclusive decisions for one attempt. Once Omni chooses commit it
|
|
2447
|
-
never rolls that attempt back:
|
|
2448
|
-
|
|
2449
|
-
|
|
2439
|
+
never rolls that attempt back: it is reconciled by snapshot until every participant is stopped.
|
|
2440
|
+
A provider that reports `granted` must therefore preserve the request across reconnects and must
|
|
2441
|
+
honour a later commit or cancel. This
|
|
2450
2442
|
durable promise prevents a provider from failing the commit after another provider has already
|
|
2451
2443
|
stopped the agent.
|
|
2452
2444
|
|
|
@@ -2463,18 +2455,19 @@ on one platform while another keeps routing work to them — and **a provider Om
|
|
|
2463
2455
|
routing nothing.** Setting it aside therefore costs none of the property it was protecting. Waiting
|
|
2464
2456
|
for it costs the agent their break.
|
|
2465
2457
|
|
|
2466
|
-
Setting a participant aside is not a rollback and not a cancel. The commit stands
|
|
2467
|
-
stands
|
|
2468
|
-
|
|
2469
|
-
the answer is `already-committed` if it applied the first one after all, and `committed` if it did
|
|
2470
|
-
not — which is how Omni tells a slow delivery from a lost one, and why that pair exists.
|
|
2458
|
+
Setting a participant aside is not a rollback and not a cancel. The commit stands and the
|
|
2459
|
+
obligation stands: until that provider is stopped it has not stopped. When it returns it emits a
|
|
2460
|
+
snapshot before anything else, and the snapshot decides:
|
|
2471
2461
|
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
against an agent who is already on break elsewhere.
|
|
2462
|
+
- `in-effect` or `starting-after-task` — the commit arrived after all. Nothing to send.
|
|
2463
|
+
- still `granted` — the commit was lost. Omni sends `commitBreak()` now. If the original turns up
|
|
2464
|
+
late behind it, the provider stops an agent who is already stopped; nothing happens, because a
|
|
2465
|
+
commit is a state to be in, not an act to be done.
|
|
2466
|
+
- `not-requested` — the grant did not survive. Omni makes a new request for that provider alone,
|
|
2467
|
+
against an agent who is already on break elsewhere. **A new login is this case too**: the grant
|
|
2468
|
+
belonged to the old session.
|
|
2469
|
+
|
|
2470
|
+
The provider must not offer work in the meantime, and the commit is what stops it.
|
|
2478
2471
|
|
|
2479
2472
|
Omni may tell the agent which platforms the break has not yet reached, as it already does when a
|
|
2480
2473
|
break cannot be paired across every provider.
|
|
@@ -2494,7 +2487,7 @@ soon as it reaches `granted`; it does not wait for unanimity because the agent h
|
|
|
2494
2487
|
stopped elsewhere.
|
|
2495
2488
|
|
|
2496
2489
|
This is two-phase coordination across vendor systems: the approval phase keeps the agent working;
|
|
2497
|
-
the durable commit decision and
|
|
2490
|
+
the durable commit decision and snapshot reconciliation provide convergence after partial delivery.
|
|
2498
2491
|
|
|
2499
2492
|
#### Reporting the break the agent is on
|
|
2500
2493
|
|
|
@@ -2505,19 +2498,19 @@ the agent on the break itself, the provider is the only one who knows.
|
|
|
2505
2498
|
Omit it when you cannot say, and when there is no break: reporting a reason alongside
|
|
2506
2499
|
`approval: "not-requested"` describes a break that is not happening, and is rejected.
|
|
2507
2500
|
|
|
2508
|
-
### `cancelBreak(
|
|
2501
|
+
### `cancelBreak()`
|
|
2509
2502
|
|
|
2510
|
-
Cancels the active pre-commit request
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2503
|
+
Cancels the active pre-commit request while its approval is `awaiting-decision` or `granted`.
|
|
2504
|
+
Cancellation releases the request but does not restore work because work never stopped. If
|
|
2505
|
+
commit already won, the provider returns `omni.break-already-committed`. The resulting state is
|
|
2506
|
+
reported through `break-state`.
|
|
2514
2507
|
|
|
2515
|
-
### `commitBreak(
|
|
2508
|
+
### `commitBreak()`
|
|
2516
2509
|
|
|
2517
|
-
Commits the
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2510
|
+
Commits the `granted` request. Once the provider has reported `granted`, it cannot fail for a
|
|
2511
|
+
business reason. On commit the provider stops offering new work and reports
|
|
2512
|
+
`starting-after-task` while existing work finishes, or `in-effect` when the break is in effect.
|
|
2513
|
+
Committing a break that is already in effect changes nothing and answers `committed`.
|
|
2521
2514
|
|
|
2522
2515
|
### `endBreak()`
|
|
2523
2516
|
|
|
@@ -2603,7 +2596,7 @@ asks for a manager, a moment the agent wants a second pair of ears. The capabili
|
|
|
2603
2596
|
team, and a second lead method beside `executeTeamBreak`:
|
|
2604
2597
|
|
|
2605
2598
|
```ts
|
|
2606
|
-
executeTeamConsult({
|
|
2599
|
+
executeTeamConsult({ command: TeamConsultCommand }): Promise<TeamCommandResult>
|
|
2607
2600
|
```
|
|
2608
2601
|
|
|
2609
2602
|
Required when the roster carries `consultControl`, and gated by it exactly as `executeTeamBreak`
|
|
@@ -2611,15 +2604,15 @@ is by `breakControl`. The flow, in order:
|
|
|
2611
2604
|
|
|
2612
2605
|
```ts
|
|
2613
2606
|
// 1. The agent asks, with a small note. Their task carries `lead` from here on.
|
|
2614
|
-
execute({
|
|
2607
|
+
execute({ taskId: "call-42", command: { type: "lead", action: "request", note: "Refund dispute, needs approval" } })
|
|
2615
2608
|
// task.lead = { status: "requested", note: "Refund dispute, needs approval", since }
|
|
2616
2609
|
|
|
2617
2610
|
// 2. Every lead entitled to it sees the request on their roster.
|
|
2618
2611
|
// team-updated: requests: [{ id: "req-7", memberId: "A-1", taskId: "call-42", note, since }]
|
|
2619
2612
|
|
|
2620
2613
|
// 3. A lead joins, or declines.
|
|
2621
|
-
executeTeamConsult({
|
|
2622
|
-
executeTeamConsult({
|
|
2614
|
+
executeTeamConsult({ command: { type: "join", requestId: "req-7" } })
|
|
2615
|
+
executeTeamConsult({ command: { type: "decline", requestId: "req-7", reason: "In a call" } })
|
|
2623
2616
|
```
|
|
2624
2617
|
|
|
2625
2618
|
**On `join` the provider bridges three parties and the lead is on a task of their own**, on the
|
|
@@ -2630,6 +2623,10 @@ trigger it; but from then on it is an outstanding task the provider counts again
|
|
|
2630
2623
|
stated ceiling like any other, nothing more is allocated to the lead while it stands, and a
|
|
2631
2624
|
provider whose lead is already at the ceiling answers the join `failed`.
|
|
2632
2625
|
|
|
2626
|
+
**A lead assists one call at a time.** A `join` from a lead already on a call -- their own or one
|
|
2627
|
+
they joined -- is answered `failed`, whatever their ceiling; the request stands for another lead,
|
|
2628
|
+
or until it is withdrawn or declined.
|
|
2629
|
+
|
|
2633
2630
|
**On `decline`, or a request the agent withdraws with `{ type: "lead", action: "cancel" }`, the
|
|
2634
2631
|
provider clears `lead` from the agent's task** and drops the request from every roster. Nothing
|
|
2635
2632
|
else changes; the agent is still on the call.
|
|
@@ -2775,12 +2772,11 @@ Command names follow the channel's operational vocabulary, and each channel's co
|
|
|
2775
2772
|
discriminated by `type` — the same discriminant `executeTeamBreak` and `custom` already use. The
|
|
2776
2773
|
unions are declared under **Shapes**.
|
|
2777
2774
|
|
|
2778
|
-
`taskId` is not repeated on the command. It travels on the `TaskCommandRequest` around it
|
|
2779
|
-
`commandId`.
|
|
2775
|
+
`taskId` is not repeated on the command. It travels on the `TaskCommandRequest` around it.
|
|
2780
2776
|
|
|
2781
|
-
**A toggle carries the state it wants, not a flip.** Inverting whatever is found cannot
|
|
2782
|
-
|
|
2783
|
-
|
|
2777
|
+
**A toggle carries the state it wants, not a flip.** Inverting whatever is found cannot converge
|
|
2778
|
+
with a stale view: a flip against a state the provider has already changed turns something on and
|
|
2779
|
+
then off again. `mute` therefore carries `muted`, and a custom `toggle` control carries its own
|
|
2784
2780
|
boolean. `hold` and `resume`, `pause` and `resume` need no flag, being pairs rather than toggles.
|
|
2785
2781
|
|
|
2786
2782
|
**`complete` sends a disposition only where one was published.** `disposition` is a
|
|
@@ -2803,8 +2799,8 @@ is already done; a failure means only that the provider did not record it, leavi
|
|
|
2803
2799
|
until the next snapshot. That is the safe direction to fail in, and it is the one place where
|
|
2804
2800
|
`failed` does not mean *nothing happened* — everywhere else it does.
|
|
2805
2801
|
|
|
2806
|
-
`mute` carries `muted` rather than flipping, so a
|
|
2807
|
-
|
|
2802
|
+
`mute` carries `muted` rather than flipping, so a stale view converges on the stated state
|
|
2803
|
+
instead of flipping it back — see **Task commands**.
|
|
2808
2804
|
|
|
2809
2805
|
### Which commands need a capability
|
|
2810
2806
|
|
|
@@ -2832,22 +2828,15 @@ confirms the end with `task-ended` and a `cancelled` outcome.
|
|
|
2832
2828
|
|
|
2833
2829
|
Applies a `TaskCommandRequest` to one provider-local task.
|
|
2834
2830
|
|
|
2835
|
-
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
`(taskId, commandId)` at most once.
|
|
2841
|
-
- A repeated successfully applied command returns `already-applied` without repeating side
|
|
2842
|
-
effects.
|
|
2843
|
-
- `applied` confirms the command side effect completed.
|
|
2844
|
-
- `failed` contains a typed `ProtocolFailure` and confirms the command was **not** applied. A
|
|
2845
|
-
command either took effect or it did not; a provider that will not and a provider that cannot
|
|
2846
|
-
report the same shape, and `code` says which.
|
|
2831
|
+
- Omni serializes commands per task and sends the next only after the previous settled or was
|
|
2832
|
+
given up as unknown; it stops sending when `task-ended` arrives.
|
|
2833
|
+
- `applied` confirms the side effect completed. `failed` confirms it did **not**, with a typed
|
|
2834
|
+
`ProtocolFailure`; a provider that will not and one that cannot report the same shape, and `code`
|
|
2835
|
+
says which.
|
|
2847
2836
|
- **A settled result is a fact; an unsettled promise is not.** Transport uncertainty may reject the
|
|
2848
|
-
promise with no result at all, and that means *unknown*, not *failed*.
|
|
2849
|
-
|
|
2850
|
-
|
|
2837
|
+
promise with no result at all, and that means *unknown*, not *failed*. `failed` must never be
|
|
2838
|
+
returned for something the provider is unsure of, because Omni will show the agent it did not
|
|
2839
|
+
happen.
|
|
2851
2840
|
|
|
2852
2841
|
### `ProtocolFailure`
|
|
2853
2842
|
|
|
@@ -3121,12 +3110,6 @@ Two properties of the harness matter to adapter authors:
|
|
|
3121
3110
|
`close()` run in a `finally` block, and a throw from any of them is reported as
|
|
3122
3111
|
`disconnectWasClean: false` rather than being hidden.
|
|
3123
3112
|
|
|
3124
|
-
### `assertCommandIdempotency(connection, request)`
|
|
3125
|
-
|
|
3126
|
-
Issues the same command twice and verifies that the first call applies (or was already applied)
|
|
3127
|
-
and the retry returns `already-applied`. Use a deterministic test task because this helper invokes
|
|
3128
|
-
the adapter command method twice.
|
|
3129
|
-
|
|
3130
3113
|
### Contract scenarios
|
|
3131
3114
|
|
|
3132
3115
|
The testing entry point also exports deterministic, reusable checks for lifecycle behavior that
|
|
@@ -3137,8 +3120,6 @@ cannot be established from TypeScript structure alone.
|
|
|
3137
3120
|
| `assertAuthenticationRestoreAndExpiry(states)` | A restored authenticated session can refresh and ends in expiry. |
|
|
3138
3121
|
| `assertReconnectWithMissedAssignments(before, reconnect, ids)` | A reconnect snapshot restores assignments received while offline. |
|
|
3139
3122
|
| `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
|
|
3140
|
-
| `assertCommandIdempotency(connection, request)` | Retrying a task command does not repeat its side effect. |
|
|
3141
|
-
| `assertDialIdempotency(connection, request)` | Retrying a dial command does not place another call. |
|
|
3142
3123
|
| `assertWrapTimeout(task, mediaEndedAt, deadline, toleranceMs?)` | The wrap deadline equals media end plus the task allowance, within a tolerance that defaults to 1000ms; a task with no allowance has no deadline, and one observed is the violation. |
|
|
3143
3124
|
| `assertBrowserIsolationAndReuse(left, right, expected)` | Browser reuse follows only the declared isolation scheme. |
|
|
3144
3125
|
| `assertNoBrowserSessionKeyCollisions(scenarios)` | No two distinct scenarios derive the same session key. Feed it adversarial names. |
|