@xema/omni-protocol 0.1.3 → 0.1.5
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 +29 -0
- package/dist/index.d.ts +114 -3
- package/dist/index.js +1 -1
- package/dist/testing.js +2 -0
- package/dist/validation.js +92 -1
- package/guide.md +169 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,6 +59,35 @@ assignments, break denial and retry, command idempotency, wrap timeout, browser
|
|
|
59
59
|
> conforming one. A suite that only asserts "this conforming case does not throw" passes unchanged
|
|
60
60
|
> if the helper is gutted, so pair every positive case with the violating twin.
|
|
61
61
|
|
|
62
|
+
## Two things TypeScript will not catch for you
|
|
63
|
+
|
|
64
|
+
Both found by adapters against this contract, and both produce a green build over a wrong shape.
|
|
65
|
+
|
|
66
|
+
**Conditional spreads are the blind spot on a task literal.** A key inside
|
|
67
|
+
`...(cond ? { … } : {})` is never checked against the task type, and `satisfies Task<C>` on the
|
|
68
|
+
surrounding literal does not reach it. Put the check on the spread operand itself:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
const task = {
|
|
72
|
+
id, title, channel: "voice", taskType, capabilities, browsers, phase, completionMode,
|
|
73
|
+
...(contact ? { contact } satisfies Partial<Task<"voice">> : {}),
|
|
74
|
+
};
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**The blind transfer arm is `action?: never`.** Switch on `command.action` with `case undefined`
|
|
78
|
+
for it, never a `default`: a `default` turns a future action into a silent blind transfer, and
|
|
79
|
+
switching on `command.action ?? "blind"` loses the narrowing that lets you read `destination`
|
|
80
|
+
without a cast. Dropping an arm then fails the build — indirectly, as a missing return.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
switch (command.action) {
|
|
84
|
+
case undefined: return blindTransfer(command.destination);
|
|
85
|
+
case "consult": return consultTransfer(command.destination);
|
|
86
|
+
case "complete": return completeConsultation();
|
|
87
|
+
case "cancel": return cancelConsultation();
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
62
91
|
## Building
|
|
63
92
|
|
|
64
93
|
```
|
package/dist/index.d.ts
CHANGED
|
@@ -293,6 +293,10 @@ export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ?
|
|
|
293
293
|
/** Reach the party again while `completing`; the task returns to `in-progress`. */
|
|
294
294
|
callback?: true;
|
|
295
295
|
blindTransfer?: true | DestinationDirectory;
|
|
296
|
+
/** Park the customer and call a destination first; then `complete` or `cancel`. */
|
|
297
|
+
consultTransfer?: true | DestinationDirectory;
|
|
298
|
+
/** Ask a lead to join this call, with a note. The lead's decision arrives on `Task.lead`. */
|
|
299
|
+
consultLead?: true;
|
|
296
300
|
conference?: true | DestinationDirectory;
|
|
297
301
|
recording?: true;
|
|
298
302
|
} : C extends "chat" ? SharedTaskCapabilities & {
|
|
@@ -392,6 +396,35 @@ export type TaskCompletion = {
|
|
|
392
396
|
completionMode: "provider-automatic";
|
|
393
397
|
completionAllowance: DurationSeconds;
|
|
394
398
|
};
|
|
399
|
+
/**
|
|
400
|
+
* A consultation in progress on a task: who is being consulted, and since when where the provider
|
|
401
|
+
* records it. Present between `transfer` `consult` and whichever of `complete` or `cancel`
|
|
402
|
+
* follows; its presence is what makes those two issuable.
|
|
403
|
+
*/
|
|
404
|
+
export interface TaskConsultation {
|
|
405
|
+
destination: string;
|
|
406
|
+
label?: string;
|
|
407
|
+
since?: IsoTimestamp;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* The agent's request for a lead, from asking until the lead leaves or the request ends.
|
|
411
|
+
* `requested` while nobody has joined; `joined`, with `leadId`, once somebody has.
|
|
412
|
+
*/
|
|
413
|
+
export interface TaskLead {
|
|
414
|
+
status: "requested" | "joined";
|
|
415
|
+
leadId?: UserId;
|
|
416
|
+
note?: string;
|
|
417
|
+
since: IsoTimestamp;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* On the lead's own task for a call they joined: which member asked, with their note. Its
|
|
421
|
+
* presence is what makes `lead` `take-over` and `leave` issuable.
|
|
422
|
+
*/
|
|
423
|
+
export interface TaskAssisting {
|
|
424
|
+
memberId: UserId;
|
|
425
|
+
note?: string;
|
|
426
|
+
since: IsoTimestamp;
|
|
427
|
+
}
|
|
395
428
|
export type Task<C extends Channel = Channel> = {
|
|
396
429
|
id: TaskId;
|
|
397
430
|
title: string;
|
|
@@ -406,7 +439,15 @@ export type Task<C extends Channel = Channel> = {
|
|
|
406
439
|
reference?: string;
|
|
407
440
|
attributes?: TaskAttribute[];
|
|
408
441
|
handlingHistory?: TaskHandlingStep[];
|
|
409
|
-
} & TaskCompletion
|
|
442
|
+
} & TaskCompletion & (C extends "voice" ? {
|
|
443
|
+
consultation?: TaskConsultation;
|
|
444
|
+
lead?: TaskLead;
|
|
445
|
+
assisting?: TaskAssisting;
|
|
446
|
+
} : {
|
|
447
|
+
consultation?: never;
|
|
448
|
+
lead?: never;
|
|
449
|
+
assisting?: never;
|
|
450
|
+
});
|
|
410
451
|
/** What the provider wants of Omni's acceptance policy for one offer. */
|
|
411
452
|
export type AcceptanceMode = "no-preference" | "require-agent-acceptance" | "require-automatic-acceptance";
|
|
412
453
|
export type TaskOutcome = {
|
|
@@ -423,12 +464,16 @@ export type TaskOutcome = {
|
|
|
423
464
|
| {
|
|
424
465
|
type: "expired";
|
|
425
466
|
phase: "pending" | "confirmed" | "preparing";
|
|
467
|
+
}
|
|
468
|
+
/** This agent left a call that continues without them: a lead who joined and dropped. */
|
|
469
|
+
| {
|
|
470
|
+
type: "left";
|
|
426
471
|
} | {
|
|
427
472
|
type: "failed";
|
|
428
473
|
failure: ProtocolFailure;
|
|
429
474
|
};
|
|
430
475
|
export declare const TASK_COMMAND_NAMES: {
|
|
431
|
-
readonly voice: readonly ["answer", "decline", "start-call", "mute", "hold", "resume", "disconnect", "callback", "transfer", "conference", "recording", "complete"];
|
|
476
|
+
readonly voice: readonly ["answer", "decline", "start-call", "mute", "hold", "resume", "disconnect", "callback", "transfer", "lead", "conference", "recording", "complete"];
|
|
432
477
|
readonly chat: readonly ["accept", "reject", "pause", "resume", "complete"];
|
|
433
478
|
readonly email: readonly ["accept", "reject", "complete"];
|
|
434
479
|
};
|
|
@@ -456,9 +501,49 @@ export type VoiceTaskCommand = {
|
|
|
456
501
|
/** Issuable only in `completing`, under the `callback` capability. Carries no destination. */
|
|
457
502
|
| {
|
|
458
503
|
type: "callback";
|
|
459
|
-
}
|
|
504
|
+
}
|
|
505
|
+
/** Blind: hand the customer to `destination` with nobody consulted. Gated by `blindTransfer`. */
|
|
506
|
+
| {
|
|
507
|
+
type: "transfer";
|
|
508
|
+
destination: string;
|
|
509
|
+
action?: never;
|
|
510
|
+
}
|
|
511
|
+
/** Park the customer and call `destination` first. Gated by `consultTransfer`. */
|
|
512
|
+
| {
|
|
460
513
|
type: "transfer";
|
|
514
|
+
action: "consult";
|
|
461
515
|
destination: string;
|
|
516
|
+
}
|
|
517
|
+
/** Hand the customer to the consulted destination and leave. Needs `Task.consultation`. */
|
|
518
|
+
| {
|
|
519
|
+
type: "transfer";
|
|
520
|
+
action: "complete";
|
|
521
|
+
}
|
|
522
|
+
/** Drop the consulted destination and return to the customer. Needs `Task.consultation`. */
|
|
523
|
+
| {
|
|
524
|
+
type: "transfer";
|
|
525
|
+
action: "cancel";
|
|
526
|
+
}
|
|
527
|
+
/** Ask a lead to join, with a note. Gated by `consultLead`. */
|
|
528
|
+
| {
|
|
529
|
+
type: "lead";
|
|
530
|
+
action: "request";
|
|
531
|
+
note?: string;
|
|
532
|
+
}
|
|
533
|
+
/** Withdraw a standing request. Needs `Task.lead` with status `requested`. */
|
|
534
|
+
| {
|
|
535
|
+
type: "lead";
|
|
536
|
+
action: "cancel";
|
|
537
|
+
}
|
|
538
|
+
/** The lead keeps the customer; the agent's task ends `transferred`. Needs `Task.assisting`. */
|
|
539
|
+
| {
|
|
540
|
+
type: "lead";
|
|
541
|
+
action: "take-over";
|
|
542
|
+
}
|
|
543
|
+
/** The lead drops; the agent continues. The lead's task ends `left`. Needs `Task.assisting`. */
|
|
544
|
+
| {
|
|
545
|
+
type: "lead";
|
|
546
|
+
action: "leave";
|
|
462
547
|
} | {
|
|
463
548
|
type: "conference";
|
|
464
549
|
participant: string;
|
|
@@ -636,9 +721,33 @@ export interface TeamMember {
|
|
|
636
721
|
* Published only to an agent entitled to one. Its presence is the permission -- nothing else
|
|
637
722
|
* makes somebody a lead, and there is no separate flag to fall out of step with the data.
|
|
638
723
|
*/
|
|
724
|
+
/** A member asking this lead to join their call. */
|
|
725
|
+
export interface LeadRequest {
|
|
726
|
+
id: string;
|
|
727
|
+
memberId: UserId;
|
|
728
|
+
taskId: TaskId;
|
|
729
|
+
note?: string;
|
|
730
|
+
since: IsoTimestamp;
|
|
731
|
+
}
|
|
639
732
|
export interface TeamRoster {
|
|
640
733
|
members: TeamMember[];
|
|
641
734
|
breakControl?: true;
|
|
735
|
+
/** Present when this lead may join a member's call on request. */
|
|
736
|
+
consultControl?: true;
|
|
737
|
+
/** Omitted when the lead may not be asked; `[]` when nobody is asking. */
|
|
738
|
+
requests?: LeadRequest[];
|
|
739
|
+
}
|
|
740
|
+
export type TeamConsultCommand = {
|
|
741
|
+
type: "join";
|
|
742
|
+
requestId: string;
|
|
743
|
+
} | {
|
|
744
|
+
type: "decline";
|
|
745
|
+
requestId: string;
|
|
746
|
+
reason?: string;
|
|
747
|
+
};
|
|
748
|
+
export interface TeamConsultCommandRequest {
|
|
749
|
+
commandId: string;
|
|
750
|
+
command: TeamConsultCommand;
|
|
642
751
|
}
|
|
643
752
|
export type TeamBreakCommand = {
|
|
644
753
|
type: "decide";
|
|
@@ -805,6 +914,8 @@ export interface Connection<C extends Channel = Channel> {
|
|
|
805
914
|
endBreak?(): Promise<BreakEndResult>;
|
|
806
915
|
/** Required when the adapter publishes a `TeamRoster` carrying `breakControl`. */
|
|
807
916
|
executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
|
|
917
|
+
/** Required when the adapter publishes a `TeamRoster` carrying `consultControl`. */
|
|
918
|
+
executeTeamConsult?(request: TeamConsultCommandRequest): Promise<TeamCommandResult>;
|
|
808
919
|
/** Required of every voice adapter: all voice audio lands in Omni. */
|
|
809
920
|
openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
|
|
810
921
|
}
|
package/dist/index.js
CHANGED
|
@@ -55,7 +55,7 @@ export function isAllowedBrowserUrl(url) {
|
|
|
55
55
|
// ---------------------------------------------------------------------------
|
|
56
56
|
export const TASK_COMMAND_NAMES = {
|
|
57
57
|
voice: ["answer", "decline", "start-call", "mute", "hold", "resume", "disconnect",
|
|
58
|
-
"callback", "transfer", "conference", "recording", "complete"],
|
|
58
|
+
"callback", "transfer", "lead", "conference", "recording", "complete"],
|
|
59
59
|
chat: ["accept", "reject", "pause", "resume", "complete"],
|
|
60
60
|
email: ["accept", "reject", "complete"],
|
|
61
61
|
};
|
package/dist/testing.js
CHANGED
|
@@ -76,6 +76,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
76
76
|
}
|
|
77
77
|
if (snapshot?.team?.breakControl === true)
|
|
78
78
|
requireMethod("executeTeamBreak", "the roster carries breakControl");
|
|
79
|
+
if (snapshot?.team?.consultControl === true)
|
|
80
|
+
requireMethod("executeTeamConsult", "the roster carries consultControl");
|
|
79
81
|
if (publishesUserIds(snapshot))
|
|
80
82
|
requireMethod("describeUsers", "the snapshot publishes a UserId");
|
|
81
83
|
// Capacity is stated, not requested: nothing may be allocated until it is, so a connection
|
package/dist/validation.js
CHANGED
|
@@ -79,7 +79,7 @@ const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
|
|
|
79
79
|
const TASK_CAPABILITIES = {
|
|
80
80
|
voice: membersOf({
|
|
81
81
|
browsers: true, dispositions: true, custom: true, decline: true, mute: true, hold: true,
|
|
82
|
-
agentDisconnect: true, callback: true, blindTransfer: true, conference: true, recording: true,
|
|
82
|
+
agentDisconnect: true, callback: true, blindTransfer: true, consultTransfer: true, consultLead: true, conference: true, recording: true,
|
|
83
83
|
}),
|
|
84
84
|
chat: membersOf({ browsers: true, dispositions: true, custom: true, reject: true, hold: true }),
|
|
85
85
|
email: membersOf({ browsers: true, dispositions: true, custom: true, reject: true }),
|
|
@@ -502,6 +502,61 @@ function validateHandlingHistory(value, path, into) {
|
|
|
502
502
|
}
|
|
503
503
|
});
|
|
504
504
|
}
|
|
505
|
+
/** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
|
|
506
|
+
function validateConsultation(value, channel, path, into) {
|
|
507
|
+
if (value === undefined)
|
|
508
|
+
return;
|
|
509
|
+
if (!into.require(channel === "voice", "task.consultation.channel", path, `a ${channel} task cannot carry a consultation`))
|
|
510
|
+
return;
|
|
511
|
+
if (!isPlainObject(value)) {
|
|
512
|
+
into.add("task.consultation.shape", path, "a consultation must be an object when present");
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
into.filled(value.destination, "task.consultation.destination", `${path}.destination`, "a consultation names the destination being consulted");
|
|
516
|
+
if (value.label !== undefined) {
|
|
517
|
+
into.filled(value.label, "task.consultation.label", `${path}.label`, "a label must not be empty when present");
|
|
518
|
+
}
|
|
519
|
+
if (value.since !== undefined)
|
|
520
|
+
into.timestamp(value.since, "task.consultation.since", `${path}.since`);
|
|
521
|
+
}
|
|
522
|
+
const LEAD_STATUSES = membersOf({ requested: true, joined: true });
|
|
523
|
+
/** The agent's request for a lead. Voice only; `joined` names the lead, `requested` cannot. */
|
|
524
|
+
function validateLead(value, channel, path, into) {
|
|
525
|
+
if (value === undefined)
|
|
526
|
+
return;
|
|
527
|
+
if (!into.require(channel === "voice", "task.lead.channel", path, `a ${channel} task cannot carry a lead request`))
|
|
528
|
+
return;
|
|
529
|
+
if (!isPlainObject(value)) {
|
|
530
|
+
into.add("task.lead.shape", path, "lead must be an object when present");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (into.oneOf(value.status, LEAD_STATUSES, "task.lead.status", `${path}.status`)) {
|
|
534
|
+
if (value.status === "joined") {
|
|
535
|
+
into.require(isUserId(value.leadId), "task.lead.leadId", `${path}.leadId`, "a joined lead is named by their user id");
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
into.require(value.leadId === undefined, "task.lead.leadId.unexpected", `${path}.leadId`, "nobody has joined a requested lead, so there is no lead to name");
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (value.note !== undefined)
|
|
542
|
+
into.filled(value.note, "task.lead.note", `${path}.note`, "a note must not be empty when present");
|
|
543
|
+
into.timestamp(value.since, "task.lead.since", `${path}.since`);
|
|
544
|
+
}
|
|
545
|
+
/** The lead's own task for a call they joined. Voice only. */
|
|
546
|
+
function validateAssisting(value, channel, path, into) {
|
|
547
|
+
if (value === undefined)
|
|
548
|
+
return;
|
|
549
|
+
if (!into.require(channel === "voice", "task.assisting.channel", path, `a ${channel} task cannot be a joined call`))
|
|
550
|
+
return;
|
|
551
|
+
if (!isPlainObject(value)) {
|
|
552
|
+
into.add("task.assisting.shape", path, "assisting must be an object when present");
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
into.require(isUserId(value.memberId), "task.assisting.memberId", `${path}.memberId`, "a joined call names the member who asked");
|
|
556
|
+
if (value.note !== undefined)
|
|
557
|
+
into.filled(value.note, "task.assisting.note", `${path}.note`, "a note must not be empty when present");
|
|
558
|
+
into.timestamp(value.since, "task.assisting.since", `${path}.since`);
|
|
559
|
+
}
|
|
505
560
|
export function validateTask(task, context, path = "task") {
|
|
506
561
|
const into = new Collector();
|
|
507
562
|
validateTaskInto(task, context, path, into);
|
|
@@ -537,6 +592,9 @@ function validateTaskInto(task, context, path, into) {
|
|
|
537
592
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
538
593
|
validateTaskAttributes(task.attributes, `${path}.attributes`, into);
|
|
539
594
|
validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into);
|
|
595
|
+
validateConsultation(task.consultation, context.channel, `${path}.consultation`, into);
|
|
596
|
+
validateLead(task.lead, context.channel, `${path}.lead`, into);
|
|
597
|
+
validateAssisting(task.assisting, context.channel, `${path}.assisting`, into);
|
|
540
598
|
const capabilities = task.capabilities;
|
|
541
599
|
if (!isPlainObject(capabilities)) {
|
|
542
600
|
into.add("task.capabilities.shape", `${path}.capabilities`, "a task needs a capabilities object");
|
|
@@ -556,6 +614,7 @@ function validateTaskInto(task, context, path, into) {
|
|
|
556
614
|
validateCustomCapabilities(declared, `${path}.capabilities.custom`, into);
|
|
557
615
|
break;
|
|
558
616
|
case "blindTransfer":
|
|
617
|
+
case "consultTransfer":
|
|
559
618
|
case "conference":
|
|
560
619
|
validateDestinationDirectory(declared, `${path}.capabilities.${name}`, into);
|
|
561
620
|
break;
|
|
@@ -650,6 +709,36 @@ function validateTeamRosterInto(roster, path, into) {
|
|
|
650
709
|
if (roster.breakControl !== undefined) {
|
|
651
710
|
into.require(roster.breakControl === true, "team.breakControl", `${path}.breakControl`, "breakControl is declared by presence: send true or omit it");
|
|
652
711
|
}
|
|
712
|
+
if (roster.consultControl !== undefined) {
|
|
713
|
+
into.require(roster.consultControl === true, "team.consultControl", `${path}.consultControl`, "consultControl is declared by presence: send true or omit it");
|
|
714
|
+
}
|
|
715
|
+
if (roster.requests !== undefined) {
|
|
716
|
+
// Requests are what a lead acts on, so a lead who may not act has no business receiving them.
|
|
717
|
+
into.require(roster.consultControl === true, "team.requests.capability", `${path}.requests`, "requests require consultControl: a lead who may not join has nothing to decide");
|
|
718
|
+
if (!Array.isArray(roster.requests)) {
|
|
719
|
+
into.add("team.requests.shape", `${path}.requests`, "requests must be an array when present");
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
const seenRequests = new Set();
|
|
723
|
+
roster.requests.forEach((request, index) => {
|
|
724
|
+
const at = `${path}.requests[${index}]`;
|
|
725
|
+
if (!isPlainObject(request)) {
|
|
726
|
+
into.add("team.request.shape", at, "each request must be an object");
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (into.filled(request.id, "team.request.id", `${at}.id`, "a request needs an id")) {
|
|
730
|
+
if (seenRequests.has(request.id))
|
|
731
|
+
into.add("team.request.unique", `${at}.id`, `duplicate request id: ${request.id}`);
|
|
732
|
+
seenRequests.add(request.id);
|
|
733
|
+
}
|
|
734
|
+
into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking");
|
|
735
|
+
into.require(isTaskId(request.taskId), "team.request.taskId", `${at}.taskId`, "a request names the task the lead would join");
|
|
736
|
+
if (request.note !== undefined)
|
|
737
|
+
into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
|
|
738
|
+
into.timestamp(request.since, "team.request.since", `${at}.since`);
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
653
742
|
if (!Array.isArray(roster.members)) {
|
|
654
743
|
into.add("team.members.shape", `${path}.members`, "a roster must carry a members array");
|
|
655
744
|
return;
|
|
@@ -770,6 +859,8 @@ function validateTaskOutcome(value, path, into) {
|
|
|
770
859
|
// Only the phases in which a task is still waiting on somebody can expire.
|
|
771
860
|
into.oneOf(value.phase, EXPIRABLE_PHASES, "event.taskEnded.outcome.expired", `${path}.phase`);
|
|
772
861
|
break;
|
|
862
|
+
case "left":
|
|
863
|
+
break;
|
|
773
864
|
case "failed":
|
|
774
865
|
if (!isPlainObject(value.failure)) {
|
|
775
866
|
into.add("event.taskEnded.outcome.failed", `${path}.failure`, "a failed outcome must carry a failure");
|
package/guide.md
CHANGED
|
@@ -337,6 +337,8 @@ type TaskCapabilities<C extends Channel = Channel> =
|
|
|
337
337
|
agentDisconnect?: true;
|
|
338
338
|
callback?: true;
|
|
339
339
|
blindTransfer?: true | DestinationDirectory;
|
|
340
|
+
consultTransfer?: true | DestinationDirectory;
|
|
341
|
+
consultLead?: true;
|
|
340
342
|
conference?: true | DestinationDirectory;
|
|
341
343
|
recording?: true;
|
|
342
344
|
}
|
|
@@ -421,6 +423,25 @@ type TaskCompletion =
|
|
|
421
423
|
| { completionMode: "agent-command"; completionAllowance?: DurationSeconds }
|
|
422
424
|
| { completionMode: "provider-automatic"; completionAllowance: DurationSeconds };
|
|
423
425
|
|
|
426
|
+
type TaskConsultation = {
|
|
427
|
+
destination: string;
|
|
428
|
+
label?: string;
|
|
429
|
+
since?: IsoTimestamp;
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
type TaskLead = {
|
|
433
|
+
status: "requested" | "joined";
|
|
434
|
+
leadId?: UserId;
|
|
435
|
+
note?: string;
|
|
436
|
+
since: IsoTimestamp;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
type TaskAssisting = {
|
|
440
|
+
memberId: UserId;
|
|
441
|
+
note?: string;
|
|
442
|
+
since: IsoTimestamp;
|
|
443
|
+
};
|
|
444
|
+
|
|
424
445
|
type Task<C extends Channel = Channel> = {
|
|
425
446
|
id: TaskId;
|
|
426
447
|
title: string;
|
|
@@ -433,7 +454,11 @@ type Task<C extends Channel = Channel> = {
|
|
|
433
454
|
reference?: string;
|
|
434
455
|
attributes?: TaskAttribute[];
|
|
435
456
|
handlingHistory?: TaskHandlingStep[];
|
|
436
|
-
} & TaskCompletion
|
|
457
|
+
} & TaskCompletion & (
|
|
458
|
+
C extends "voice"
|
|
459
|
+
? { consultation?: TaskConsultation; lead?: TaskLead; assisting?: TaskAssisting }
|
|
460
|
+
: { consultation?: never; lead?: never; assisting?: never }
|
|
461
|
+
);
|
|
437
462
|
|
|
438
463
|
type AcceptanceMode =
|
|
439
464
|
| "no-preference"
|
|
@@ -445,6 +470,7 @@ type TaskOutcome =
|
|
|
445
470
|
| { type: "transferred"; destination?: string }
|
|
446
471
|
| { type: "cancelled"; reason?: string }
|
|
447
472
|
| { type: "expired"; phase: "pending" | "confirmed" | "preparing" }
|
|
473
|
+
| { type: "left" }
|
|
448
474
|
| { type: "failed"; failure: ProtocolFailure };
|
|
449
475
|
```
|
|
450
476
|
|
|
@@ -462,6 +488,7 @@ const TASK_COMMAND_NAMES = {
|
|
|
462
488
|
"disconnect",
|
|
463
489
|
"callback",
|
|
464
490
|
"transfer",
|
|
491
|
+
"lead",
|
|
465
492
|
"conference",
|
|
466
493
|
"recording",
|
|
467
494
|
"complete",
|
|
@@ -484,7 +511,14 @@ type VoiceTaskCommand =
|
|
|
484
511
|
| { type: "resume" }
|
|
485
512
|
| { type: "disconnect" }
|
|
486
513
|
| { type: "callback" }
|
|
487
|
-
| { type: "transfer"; destination: string }
|
|
514
|
+
| { type: "transfer"; destination: string; action?: never }
|
|
515
|
+
| { type: "transfer"; action: "consult"; destination: string }
|
|
516
|
+
| { type: "transfer"; action: "complete" }
|
|
517
|
+
| { type: "transfer"; action: "cancel" }
|
|
518
|
+
| { type: "lead"; action: "request"; note?: string }
|
|
519
|
+
| { type: "lead"; action: "cancel" }
|
|
520
|
+
| { type: "lead"; action: "take-over" }
|
|
521
|
+
| { type: "lead"; action: "leave" }
|
|
488
522
|
| { type: "conference"; participant: string; action: "add" | "remove" }
|
|
489
523
|
| { type: "recording"; action: "start" | "pause" | "resume" | "stop" }
|
|
490
524
|
| ({ type: "complete" } & DispositionPayload);
|
|
@@ -567,11 +601,25 @@ type TeamMember = {
|
|
|
567
601
|
break?: BreakApproval;
|
|
568
602
|
};
|
|
569
603
|
|
|
604
|
+
type LeadRequest = {
|
|
605
|
+
id: string;
|
|
606
|
+
memberId: UserId;
|
|
607
|
+
taskId: TaskId;
|
|
608
|
+
note?: string;
|
|
609
|
+
since: IsoTimestamp;
|
|
610
|
+
};
|
|
611
|
+
|
|
570
612
|
type TeamRoster = {
|
|
571
613
|
members: TeamMember[];
|
|
572
614
|
breakControl?: true;
|
|
615
|
+
consultControl?: true;
|
|
616
|
+
requests?: LeadRequest[];
|
|
573
617
|
};
|
|
574
618
|
|
|
619
|
+
type TeamConsultCommand =
|
|
620
|
+
| { type: "join"; requestId: string }
|
|
621
|
+
| { type: "decline"; requestId: string; reason?: string };
|
|
622
|
+
|
|
575
623
|
type TeamBreakCommand =
|
|
576
624
|
| { type: "decide"; memberId: UserId; decision: "granted" | "denied"; reason?: string }
|
|
577
625
|
| { type: "policy"; policy: "ask" | "auto-approve" | "suspended" }
|
|
@@ -1427,6 +1475,7 @@ surface in one place, and what obliges an adapter to implement each one.
|
|
|
1427
1475
|
| `cancelBreak(requestId)` | `sessionCapabilities.breaks` is declared. |
|
|
1428
1476
|
| `endBreak()` | `sessionCapabilities.breaks` is declared. |
|
|
1429
1477
|
| `executeTeamBreak(command)` | The adapter publishes a `TeamRoster` carrying `breakControl`. |
|
|
1478
|
+
| `executeTeamConsult(command)` | The adapter publishes a `TeamRoster` carrying `consultControl`. |
|
|
1430
1479
|
| `openMedia(request)` | The manifest channel is `voice`. Every voice task's audio lands in Omni, so there is no voice adapter that does not implement it. |
|
|
1431
1480
|
|
|
1432
1481
|
**The four break methods stand or fall together.** Declaring `sessionCapabilities.breaks` and then
|
|
@@ -1624,6 +1673,9 @@ time. Runtime conformance checks also require the task channel to match its prov
|
|
|
1624
1673
|
| `completionAllowance` | Fixed time allowed to complete the task after primary handling ends. For real-time media, it begins after `task-media-ended`. Required under `provider-automatic`, where the provider acts on it. Optional under `agent-command`: omitted says the provider imposes no deadline, and Omni counts nothing down. |
|
|
1625
1674
|
| `attributes` | Optional ordered, typed `TaskAttribute` entries with keys unique within the task. Each contact or timestamp is a separate array item; new attribute shapes require new union members. |
|
|
1626
1675
|
| `handlingHistory` | Optional ordered handling history for this currently open task. It is live task data, not a permanent archive. |
|
|
1676
|
+
| `consultation` | Voice only. Present while the agent is consulting a transfer destination: who is being consulted, and since when where the provider records it. Its presence is what makes `transfer` `complete` and `cancel` issuable. `label` is a name for the destination -- a person, a queue -- not a phrase; the host supplies the verb. See **Consult transfer**. |
|
|
1677
|
+
| `lead` | Voice only. Present from the agent's request for a lead until the lead leaves or the request ends: `requested` while nobody has joined, `joined` with the lead's `leadId` once somebody has. See **Consulting a lead**. |
|
|
1678
|
+
| `assisting` | Voice only, on the lead's own task for a call they joined: which member asked, with their note. Its presence is what makes `lead` `take-over` and `leave` issuable. See **Consulting a lead**. |
|
|
1627
1679
|
|
|
1628
1680
|
`TaskAttribute` entries carry typed detail alongside the task:
|
|
1629
1681
|
|
|
@@ -1659,7 +1711,9 @@ The canonical task transitions are:
|
|
|
1659
1711
|
| `pending` | Provider withdraws the allocation | Removed by `task-ended` with `cancelled` outcome |
|
|
1660
1712
|
| No task | Snapshot reports work already underway | `in-progress` |
|
|
1661
1713
|
| `in-progress` | Provider or agent pauses the task | `paused` |
|
|
1714
|
+
| `in-progress` | Agent consults a transfer destination (`transfer` `consult`); the customer is parked | `paused` |
|
|
1662
1715
|
| `paused` | Provider or agent resumes the task | `in-progress` |
|
|
1716
|
+
| `paused` | Agent cancels a consultation (`transfer` `cancel`) | `in-progress` |
|
|
1663
1717
|
| `in-progress` or `paused` | Contact handling ends and follow-up work remains | `completing` |
|
|
1664
1718
|
| `completing` | Agent calls the party back (`callback`) | `in-progress` |
|
|
1665
1719
|
| Any phase | Provider emits `task-ended` | Removed |
|
|
@@ -1947,13 +2001,15 @@ const taskCapabilities = {
|
|
|
1947
2001
|
| `agentDisconnect` | Primary button: Disconnect | Omni may disconnect real-time media without disposing the task. |
|
|
1948
2002
|
| `callback` | Completing-task button: Call back | Omni may have the provider call the task's party back while the task is `completing`, returning it to `in-progress` on the same task. Not offered where there is no `completing` window: `provider-automatic` with a zero allowance disposes at provider end. See **Calling back during completion**. |
|
|
1949
2003
|
| `blindTransfer` | Secondary menu item: Blind transfer | Omni may transfer the caller directly to a destination. |
|
|
2004
|
+
| `consultTransfer` | Secondary menu item: Consult transfer | Omni may park the customer and call a destination first, then hand the customer over or cancel back. See **Consult transfer**. |
|
|
2005
|
+
| `consultLead` | Secondary menu item: Consult lead | Omni may ask a lead to join this call, with a note. The lead's decision reaches the agent on `Task.lead`. See **Consulting a lead**. |
|
|
1950
2006
|
| `conference` | Secondary button: Conference | Omni may add or remove participants from the active call. |
|
|
1951
2007
|
| `recording` | Overflow menu item: Recording | Omni may expose start, pause, resume, and stop recording controls. |
|
|
1952
2008
|
| `dispositions` | Primary button: Complete | Omni may request task disposal with a provider disposition and notes. |
|
|
1953
2009
|
|
|
1954
2010
|
### Publishing codes and destinations
|
|
1955
2011
|
|
|
1956
|
-
|
|
2012
|
+
Four capabilities accept an object instead of `true` when the provider wants Omni to render real
|
|
1957
2013
|
choices. `true` remains valid and means "offer the control with nothing published".
|
|
1958
2014
|
|
|
1959
2015
|
#### `dispositions`
|
|
@@ -1980,7 +2036,7 @@ capabilities: {
|
|
|
1980
2036
|
With `dispositions: true` Omni shows a Complete control and sends `complete` with no code, because
|
|
1981
2037
|
the provider published none.
|
|
1982
2038
|
|
|
1983
|
-
#### `blindTransfer` and `conference`
|
|
2039
|
+
#### `blindTransfer`, `consultTransfer` and `conference`
|
|
1984
2040
|
|
|
1985
2041
|
```ts
|
|
1986
2042
|
capabilities: {
|
|
@@ -2012,6 +2068,42 @@ A destination the agent types is not in the directory and has no `kind`. Omni tr
|
|
|
2012
2068
|
`external` unless the provider says otherwise in its response, because that is the assumption that
|
|
2013
2069
|
does not overstate what the provider can still see.
|
|
2014
2070
|
|
|
2071
|
+
#### Consult transfer
|
|
2072
|
+
|
|
2073
|
+
A consult transfer parks the customer, calls the destination so the agent can speak to it first,
|
|
2074
|
+
and then either hands the customer over or returns to them. It is its own capability, distinct
|
|
2075
|
+
from `blindTransfer` (a hand-over with nobody consulted) and from `conference` (everybody on one
|
|
2076
|
+
call): a queue may offer any of the three without the others, and each is declared on its own.
|
|
2077
|
+
|
|
2078
|
+
```ts
|
|
2079
|
+
// 1. Consult. The provider parks the customer and calls the destination; the task reports
|
|
2080
|
+
// `paused` and carries `consultation` while the call to the destination stands.
|
|
2081
|
+
{ type: "transfer", action: "consult", destination: "+14155550111" }
|
|
2082
|
+
|
|
2083
|
+
// 2a. Hand the customer to the consulted destination and leave.
|
|
2084
|
+
{ type: "transfer", action: "complete" }
|
|
2085
|
+
|
|
2086
|
+
// 2b. Or drop the destination and return to the customer.
|
|
2087
|
+
{ type: "transfer", action: "cancel" }
|
|
2088
|
+
```
|
|
2089
|
+
|
|
2090
|
+
`consult` is gated by the `consultTransfer` capability and takes a destination exactly as a blind
|
|
2091
|
+
transfer does, from the same kind of directory. While the consultation stands the task carries
|
|
2092
|
+
`consultation`, and that presence is what makes `complete` and `cancel` issuable -- they name no
|
|
2093
|
+
destination because there is exactly one they could mean. A consultation that could be started
|
|
2094
|
+
but not finished would strand the customer and the destination both, which is why all three are
|
|
2095
|
+
commands and a provider that offers `consultTransfer` implements all three.
|
|
2096
|
+
|
|
2097
|
+
`applied` on `complete` says the provider is bridging the customer to the destination and
|
|
2098
|
+
dropping the agent's leg. What follows is what follows any transfer: the agent's media ends and
|
|
2099
|
+
the provider reports `task-media-ended`, any completion allowance runs, and the task ends with a
|
|
2100
|
+
`transferred` outcome naming the destination. `applied` on `cancel` says the destination is
|
|
2101
|
+
dropped; the task returns to `in-progress` with `consultation` gone. Omni waits for the
|
|
2102
|
+
provider's report of both, as it does for every command.
|
|
2103
|
+
|
|
2104
|
+
A destination that does not answer is a consultation that ended: the provider clears
|
|
2105
|
+
`consultation`, returns the task to `in-progress`, and the agent is back with the customer.
|
|
2106
|
+
|
|
2015
2107
|
### Chat capabilities
|
|
2016
2108
|
|
|
2017
2109
|
| Capability | Omni UI | Contract |
|
|
@@ -2444,6 +2536,8 @@ A lead who also takes calls sees their team on the idle dashboard. `Snapshot.tea
|
|
|
2444
2536
|
| --- | --- |
|
|
2445
2537
|
| `members` | Every member of this lead's team, whatever their state. `[]` says the lead has a team with nobody in it; omitting the roster says something else entirely — see **Its presence is the permission** below. |
|
|
2446
2538
|
| `breakControl` | Present when this lead decides their team's breaks, absent when they do not. |
|
|
2539
|
+
| `consultControl` | Present when this lead may join a member's call on request, absent when they may not. |
|
|
2540
|
+
| `requests` | The members currently asking this lead to join a call, each with the task and the note. Omitted when the lead may not be asked; `[]` when nobody is asking. See **Consulting a lead**. |
|
|
2447
2541
|
|
|
2448
2542
|
| `TeamMember` field | Contract |
|
|
2449
2543
|
| --- | --- |
|
|
@@ -2501,6 +2595,70 @@ on their own `BreakState`, or they are stopped from working with no way to see w
|
|
|
2501
2595
|
What happens when no lead is online — auto-approving, for instance — is the provider's decision and is
|
|
2502
2596
|
never expressed here.
|
|
2503
2597
|
|
|
2598
|
+
### Consulting a lead
|
|
2599
|
+
|
|
2600
|
+
An agent on a call may ask a lead to join it -- a dispute that needs approval, a customer who
|
|
2601
|
+
asks for a manager, a moment the agent wants a second pair of ears. The capability is
|
|
2602
|
+
`consultLead` on the task; the lead's side is the roster, which is already the lead's view of the
|
|
2603
|
+
team, and a second lead method beside `executeTeamBreak`:
|
|
2604
|
+
|
|
2605
|
+
```ts
|
|
2606
|
+
executeTeamConsult({ commandId, command: TeamConsultCommand }): Promise<TeamCommandResult>
|
|
2607
|
+
```
|
|
2608
|
+
|
|
2609
|
+
Required when the roster carries `consultControl`, and gated by it exactly as `executeTeamBreak`
|
|
2610
|
+
is by `breakControl`. The flow, in order:
|
|
2611
|
+
|
|
2612
|
+
```ts
|
|
2613
|
+
// 1. The agent asks, with a small note. Their task carries `lead` from here on.
|
|
2614
|
+
execute({ commandId, taskId: "call-42", command: { type: "lead", action: "request", note: "Refund dispute, needs approval" } })
|
|
2615
|
+
// task.lead = { status: "requested", note: "Refund dispute, needs approval", since }
|
|
2616
|
+
|
|
2617
|
+
// 2. Every lead entitled to it sees the request on their roster.
|
|
2618
|
+
// team-updated: requests: [{ id: "req-7", memberId: "A-1", taskId: "call-42", note, since }]
|
|
2619
|
+
|
|
2620
|
+
// 3. A lead joins, or declines.
|
|
2621
|
+
executeTeamConsult({ commandId, command: { type: "join", requestId: "req-7" } })
|
|
2622
|
+
executeTeamConsult({ commandId, command: { type: "decline", requestId: "req-7", reason: "In a call" } })
|
|
2623
|
+
```
|
|
2624
|
+
|
|
2625
|
+
**On `join` the provider bridges three parties and the lead is on a task of their own**, on the
|
|
2626
|
+
same task id, arriving on the lead's connection as `task-offered` with `require-automatic-acceptance`
|
|
2627
|
+
-- the way a call an agent placed themselves arrives -- and carrying `assisting`. The agent's task
|
|
2628
|
+
moves to `lead: { status: "joined", leadId }`. A join is the lead's own act, so capacity does not
|
|
2629
|
+
trigger it; but from then on it is an outstanding task the provider counts against the lead's
|
|
2630
|
+
stated ceiling like any other, nothing more is allocated to the lead while it stands, and a
|
|
2631
|
+
provider whose lead is already at the ceiling answers the join `failed`.
|
|
2632
|
+
|
|
2633
|
+
**On `decline`, or a request the agent withdraws with `{ type: "lead", action: "cancel" }`, the
|
|
2634
|
+
provider clears `lead` from the agent's task** and drops the request from every roster. Nothing
|
|
2635
|
+
else changes; the agent is still on the call.
|
|
2636
|
+
|
|
2637
|
+
The lead then has two commands on their copy, gated by `assisting` being present, and a third
|
|
2638
|
+
choice that is no command at all:
|
|
2639
|
+
|
|
2640
|
+
| The lead | The agent's task | The lead's task |
|
|
2641
|
+
| --- | --- | --- |
|
|
2642
|
+
| `{ type: "lead", action: "take-over" }` | `task-ended` with `{ type: "transferred", destination: leadId }`, straight from `in-progress`: **no `completing` window**, the agent is idle at once | Continues alone, and ends as any call does |
|
|
2643
|
+
| `{ type: "lead", action: "leave" }` | Continues; `lead` is cleared | `task-ended` with `{ type: "left" }` -- the call goes on without them |
|
|
2644
|
+
| Stays until the customer hangs up | `task-media-ended`, `completing`, its own disposition | The same, independently: **both have the disposal window** |
|
|
2645
|
+
|
|
2646
|
+
`left` is the one outcome that ends a task without ending the call: this agent left a call that
|
|
2647
|
+
continues without them. It reads as neither a completion nor a cancellation, because it is
|
|
2648
|
+
neither.
|
|
2649
|
+
|
|
2650
|
+
```ts
|
|
2651
|
+
const consultLeadCapable = {
|
|
2652
|
+
channel: "voice",
|
|
2653
|
+
capabilities: { hold: true, consultLead: true, dispositions: true },
|
|
2654
|
+
phase: "in-progress",
|
|
2655
|
+
lead: { status: "joined", leadId: "L-9", note: "Refund dispute, needs approval", since: "2026-08-21T09:04:00Z" },
|
|
2656
|
+
} satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "lead">;
|
|
2657
|
+
```
|
|
2658
|
+
|
|
2659
|
+
Lead and member alike are `UserId`s of this provider, so an adapter publishing them implements
|
|
2660
|
+
`describeUsers()`; names never travel on a task or a roster.
|
|
2661
|
+
|
|
2504
2662
|
### A member waiting for a break
|
|
2505
2663
|
|
|
2506
2664
|
A member who has asked for a break **keeps working** until Omni commits it, so asking is not an
|
|
@@ -2661,6 +2819,10 @@ declared:
|
|
|
2661
2819
|
| `start-call` | The `preparing` phase. It starts the contact a preview gave the agent time to read, so the phase is the gate and there is no capability. |
|
|
2662
2820
|
| `complete` | `completionMode: "agent-command"`. The `dispositions` capability decides whether a code travels with the command, never whether the command exists — a task Omni cannot complete never ends. |
|
|
2663
2821
|
| `callback` | The `callback` capability **and** the `completing` phase. It exists to reach the party again after the call, so it has no meaning while the call is up. |
|
|
2822
|
+
| `transfer` with `action: "consult"` | The `consultTransfer` capability. Blind `transfer` is gated by `blindTransfer`; the two are declared and offered separately. |
|
|
2823
|
+
| `transfer` with `action: "complete"` or `"cancel"` | A consultation in progress -- `Task.consultation` present. Without one there is nothing to complete or cancel, and a provider that receives either answers `failed`. |
|
|
2824
|
+
| `lead` with `action: "request"` or `"cancel"` | The `consultLead` capability. `cancel` needs a request standing -- `Task.lead` with status `requested`. |
|
|
2825
|
+
| `lead` with `action: "take-over"` or `"leave"` | The lead's own task, on a call they joined -- `Task.assisting` present. An agent's task never has it, and a provider that receives either without it answers `failed`. |
|
|
2664
2826
|
| Everything else | Its own named capability. |
|
|
2665
2827
|
|
|
2666
2828
|
Declining or rejecting a pending offer ends it without accepting or completing it. The provider
|
|
@@ -2841,6 +3003,9 @@ Every outcome ends the task for this agent. On `task-ended`, Omni:
|
|
|
2841
3003
|
- releases task-scoped resources; and
|
|
2842
3004
|
- selects another task or returns to the idle workspace.
|
|
2843
3005
|
|
|
3006
|
+
A `left` outcome ends the task for this agent alone: the call continues without them, as it does
|
|
3007
|
+
when a lead who joined it leaves -- see **Consulting a lead**.
|
|
3008
|
+
|
|
2844
3009
|
A successful `complete` or `transfer` command does not clear the task. Omni waits for `task-ended`.
|
|
2845
3010
|
The `task-media-ended` event and the `completing` phase are likewise non-terminal. A replacement
|
|
2846
3011
|
snapshot that no longer contains the task also clears it. Repeated `task-ended` delivery with the
|