@xema/omni-protocol 0.1.102 → 0.1.103
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 +40 -28
- package/dist/index.js +4 -2
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +64 -2
- package/dist/validation.d.ts +2 -0
- package/dist/validation.js +32 -13
- package/guide.md +94 -46
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -179,12 +179,12 @@ export interface Manifest<C extends Channel = Channel> {
|
|
|
179
179
|
*/
|
|
180
180
|
runningStepReports?: true;
|
|
181
181
|
/**
|
|
182
|
-
* How long after
|
|
183
|
-
* `task-ended`
|
|
184
|
-
*
|
|
185
|
-
*
|
|
182
|
+
* How long after `applied` the wire shows what the provider did, in milliseconds: the
|
|
183
|
+
* `task-ended` after a `complete` or a lead's `take-over-call`, the `calendar-updated` after a
|
|
184
|
+
* `schedule`. Past it the host resyncs and shows the thing as unsettled. Stated per provider,
|
|
185
|
+
* since platforms settle at different speeds.
|
|
186
186
|
*/
|
|
187
|
-
|
|
187
|
+
settleMs: number;
|
|
188
188
|
}
|
|
189
189
|
export interface SecretStore {
|
|
190
190
|
get(key: string): Promise<string | undefined>;
|
|
@@ -666,8 +666,10 @@ export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ?
|
|
|
666
666
|
} : C extends "chat" ? SharedTaskCapabilities & {
|
|
667
667
|
decline?: Lockable<true>;
|
|
668
668
|
hold?: Lockable<true>;
|
|
669
|
+
schedule?: Lockable<true>;
|
|
669
670
|
} : SharedTaskCapabilities & {
|
|
670
671
|
decline?: Lockable<true>;
|
|
672
|
+
schedule?: Lockable<true>;
|
|
671
673
|
};
|
|
672
674
|
/**
|
|
673
675
|
* How a reusing browser's session is keyed.
|
|
@@ -948,6 +950,13 @@ export type Task<C extends Channel = Channel> = {
|
|
|
948
950
|
* Omni said not, and absent past `pending`.
|
|
949
951
|
*/
|
|
950
952
|
acceptance?: AcceptanceMode;
|
|
953
|
+
/**
|
|
954
|
+
* In `pending` only: how long the offer has left, in whole seconds from this publication,
|
|
955
|
+
* restated on every publication that carries it so a reconnect snapshot says what is actually
|
|
956
|
+
* left. Absent, the offer stands until the provider says otherwise. Runs out: the provider ends
|
|
957
|
+
* the offer `expired` naming `pending`.
|
|
958
|
+
*/
|
|
959
|
+
expiresInSeconds?: DurationSeconds;
|
|
951
960
|
/**
|
|
952
961
|
* In `preview` only, and together: how long the preparation has left, in whole seconds from
|
|
953
962
|
* this publication -- restated on every publication that carries it, so a resync starts the
|
|
@@ -999,18 +1008,18 @@ export type TaskOutcome = {
|
|
|
999
1008
|
by: "agent" | "provider" | "party";
|
|
1000
1009
|
reason?: string;
|
|
1001
1010
|
}
|
|
1002
|
-
/** Only the phases in which somebody is still being waited on can expire
|
|
1011
|
+
/** Only the phases in which somebody is still being waited on can expire: an offer whose `expiresInSeconds` runs out names `pending`, accepted work the platform gave up on names `confirmed`. A preview never lapses: withdrawn, it is `cancelled` by the provider. */
|
|
1003
1012
|
| {
|
|
1004
1013
|
type: "expired";
|
|
1005
|
-
phase: "pending" | "confirmed"
|
|
1014
|
+
phase: "pending" | "confirmed";
|
|
1006
1015
|
} | {
|
|
1007
1016
|
type: "failed";
|
|
1008
1017
|
failure: ProtocolFailure;
|
|
1009
1018
|
};
|
|
1010
1019
|
export declare const TASK_COMMAND_NAMES: {
|
|
1011
1020
|
readonly voice: readonly ["answer", "decline", "dial", "hold", "resume", "end-call", "terminate-call", "connect-back", "lead-assist", "conference", "recording", "schedule", "complete"];
|
|
1012
|
-
readonly chat: readonly ["accept", "decline", "pause", "resume", "complete"];
|
|
1013
|
-
readonly email: readonly ["accept", "decline", "complete"];
|
|
1021
|
+
readonly chat: readonly ["accept", "decline", "pause", "resume", "schedule", "complete"];
|
|
1022
|
+
readonly email: readonly ["accept", "decline", "schedule", "complete"];
|
|
1014
1023
|
};
|
|
1015
1024
|
export type TaskCommandName<C extends keyof typeof TASK_COMMAND_NAMES> = (typeof TASK_COMMAND_NAMES)[C][number];
|
|
1016
1025
|
export interface OutcomePayload {
|
|
@@ -1043,17 +1052,7 @@ export type VoiceTaskCommand = {
|
|
|
1043
1052
|
| {
|
|
1044
1053
|
type: "connect-back";
|
|
1045
1054
|
dialId: DialId;
|
|
1046
|
-
}
|
|
1047
|
-
/**
|
|
1048
|
-
* Put a time on the calendar for this party: a follow-up promised on the call. Gated by
|
|
1049
|
-
* `schedule`, issuable in `in-progress`, `paused` or `completing`; the activity arrives on
|
|
1050
|
-
* `calendar-updated`, which is the provider's word that it stands.
|
|
1051
|
-
*/
|
|
1052
|
-
| {
|
|
1053
|
-
type: "schedule";
|
|
1054
|
-
at: IsoTimestamp;
|
|
1055
|
-
note?: string;
|
|
1056
|
-
}
|
|
1055
|
+
} | ScheduleCommand
|
|
1057
1056
|
/** Ask a lead to join, with a note. Gated by `leadAssist`. */
|
|
1058
1057
|
| {
|
|
1059
1058
|
type: "lead-assist";
|
|
@@ -1093,6 +1092,18 @@ export type VoiceTaskCommand = {
|
|
|
1093
1092
|
}) | ({
|
|
1094
1093
|
type: "complete";
|
|
1095
1094
|
} & OutcomePayload);
|
|
1095
|
+
/**
|
|
1096
|
+
* Put a time on the calendar for this party: a follow-up promised on the call or in the
|
|
1097
|
+
* conversation, or written up in wrap. Gated by `schedule` on any channel, issuable in
|
|
1098
|
+
* `in-progress`, `paused` or `completing`, under a manifest that declares `calendar` for it to
|
|
1099
|
+
* land on; `applied` says it is on the calendar, and the `calendar-updated` that shows it follows
|
|
1100
|
+
* within the manifest's `settleMs`.
|
|
1101
|
+
*/
|
|
1102
|
+
export type ScheduleCommand = {
|
|
1103
|
+
type: "schedule";
|
|
1104
|
+
at: IsoTimestamp;
|
|
1105
|
+
note?: string;
|
|
1106
|
+
};
|
|
1096
1107
|
export type ChatTaskCommand = {
|
|
1097
1108
|
type: "accept";
|
|
1098
1109
|
} | {
|
|
@@ -1101,14 +1112,14 @@ export type ChatTaskCommand = {
|
|
|
1101
1112
|
type: "pause";
|
|
1102
1113
|
} | {
|
|
1103
1114
|
type: "resume";
|
|
1104
|
-
} | ({
|
|
1115
|
+
} | ScheduleCommand | ({
|
|
1105
1116
|
type: "complete";
|
|
1106
1117
|
} & OutcomePayload);
|
|
1107
1118
|
export type EmailTaskCommand = {
|
|
1108
1119
|
type: "accept";
|
|
1109
1120
|
} | {
|
|
1110
1121
|
type: "decline";
|
|
1111
|
-
} | ({
|
|
1122
|
+
} | ScheduleCommand | ({
|
|
1112
1123
|
type: "complete";
|
|
1113
1124
|
} & OutcomePayload);
|
|
1114
1125
|
export interface CustomTaskCommand {
|
|
@@ -1187,9 +1198,11 @@ export interface BreakRequest {
|
|
|
1187
1198
|
reasonId?: string;
|
|
1188
1199
|
}
|
|
1189
1200
|
/**
|
|
1190
|
-
* A break forced on the agent; the agent must explicitly resume when ready.
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
1201
|
+
* A break forced on the agent; the agent must explicitly resume when ready. A lead's `force-break`
|
|
1202
|
+
* is a command to the provider, which forces the break: `by` names the lead who asked it to, or
|
|
1203
|
+
* `provider` where the platform itself did, on its own rule or a schedule. The agent sees who, a
|
|
1204
|
+
* person is named only when there is one, and a break the platform imposed is the platform's to
|
|
1205
|
+
* lift: a lead's `end-forced-break` on it fails with `omni.break-forced-by-provider`.
|
|
1193
1206
|
*/
|
|
1194
1207
|
export type ForcedBreak = {
|
|
1195
1208
|
by: UserId | "provider";
|
|
@@ -1322,6 +1335,7 @@ export interface MemberShift {
|
|
|
1322
1335
|
signedOutAt?: IsoTimestamp;
|
|
1323
1336
|
talkSeconds?: DurationSeconds;
|
|
1324
1337
|
holdSeconds?: DurationSeconds;
|
|
1338
|
+
/** Every break the member was on today, including time held elsewhere for a break another provider forced: the provider counts it under a break kind of its own. */
|
|
1325
1339
|
breakSeconds?: DurationSeconds;
|
|
1326
1340
|
tasksHandled?: number;
|
|
1327
1341
|
events?: ShiftEvent[];
|
|
@@ -1590,8 +1604,6 @@ export type ProviderEvent<C extends Channel = Channel> = {
|
|
|
1590
1604
|
} | {
|
|
1591
1605
|
type: "task-offered";
|
|
1592
1606
|
task: Task<C>;
|
|
1593
|
-
/** How long the agent has to answer, in whole seconds from this event; the offer expires when it runs out. Absent, it stands until the provider says otherwise. */
|
|
1594
|
-
expiresInSeconds?: DurationSeconds;
|
|
1595
1607
|
} | {
|
|
1596
1608
|
type: "task-updated";
|
|
1597
1609
|
task: Task<C>;
|
|
@@ -1669,7 +1681,7 @@ export interface ProviderEventEnvelope<C extends Channel = Channel> {
|
|
|
1669
1681
|
occurredAt: IsoTimestamp;
|
|
1670
1682
|
event: ProviderEvent<C>;
|
|
1671
1683
|
}
|
|
1672
|
-
export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.assignment-not-found", "omni.destination-not-permitted", "omni.phone-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed", "omni.recording-unsettled"];
|
|
1684
|
+
export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.assignment-not-found", "omni.destination-not-permitted", "omni.phone-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed", "omni.recording-unsettled", "omni.break-forced-by-provider"];
|
|
1673
1685
|
export type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
|
|
1674
1686
|
/**
|
|
1675
1687
|
* What the host refused, told to the adapter that published it: a snapshot the host would not
|
package/dist/index.js
CHANGED
|
@@ -78,8 +78,8 @@ export const CAPABILITY_SOURCES = ["queue", "nobody", "not-yet-read"];
|
|
|
78
78
|
export const TASK_COMMAND_NAMES = {
|
|
79
79
|
voice: ["answer", "decline", "dial", "hold", "resume", "end-call", "terminate-call",
|
|
80
80
|
"connect-back", "lead-assist", "conference", "recording", "schedule", "complete"],
|
|
81
|
-
chat: ["accept", "decline", "pause", "resume", "complete"],
|
|
82
|
-
email: ["accept", "decline", "complete"],
|
|
81
|
+
chat: ["accept", "decline", "pause", "resume", "schedule", "complete"],
|
|
82
|
+
email: ["accept", "decline", "schedule", "complete"],
|
|
83
83
|
};
|
|
84
84
|
/** The dial a command places, by the host's identity for it; `undefined` for a command that dials nothing. */
|
|
85
85
|
export function commandDialId(command) {
|
|
@@ -105,6 +105,8 @@ export const OMNI_FAILURE_CODES = [
|
|
|
105
105
|
"omni.break-already-committed",
|
|
106
106
|
/** A recording command the provider could not settle either way: no start, stop, pause or resume it can vouch for. */
|
|
107
107
|
"omni.recording-unsettled",
|
|
108
|
+
/** A lead asked to lift a break the platform itself imposed; the platform lifts its own. */
|
|
109
|
+
"omni.break-forced-by-provider",
|
|
108
110
|
];
|
|
109
111
|
/**
|
|
110
112
|
* Preserves the adapter's inferred concrete type while checking it implements `Adapter`.
|
package/dist/testing.d.ts
CHANGED
|
@@ -249,6 +249,13 @@ export interface BreakCandidate {
|
|
|
249
249
|
/** Whether the agent can currently receive work from it: connected, with a capacity stated. */
|
|
250
250
|
holdsCapacity: boolean;
|
|
251
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* A break one provider forces stops the agent everywhere: the host states a capacity of zero on
|
|
254
|
+
* every other usable provider holding capacity -- taken, never refused, and nothing the agent can
|
|
255
|
+
* cancel -- and on the forcing provider states nothing, since the break itself holds the agent
|
|
256
|
+
* there. A provider whose login is not usable is left alone, as a break attempt leaves it.
|
|
257
|
+
*/
|
|
258
|
+
export declare function assertForcedBreakStopsTheRest(forcedOn: string, candidates: readonly BreakCandidate[], stopped: readonly string[]): void;
|
|
252
259
|
/**
|
|
253
260
|
* A break attempt asks every connected provider from which the agent can currently receive work.
|
|
254
261
|
* A provider whose login is not usable -- `expired` above all -- is not asked, whatever else is
|
package/dist/testing.js
CHANGED
|
@@ -1626,6 +1626,7 @@ async function driveOneCall(drive) {
|
|
|
1626
1626
|
const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`, {
|
|
1627
1627
|
levels: effectiveLevels(drive.manifest.orgLevels).map(level => level.id),
|
|
1628
1628
|
dialOutcomesDeclared: drive.manifest.dialOutcomes !== undefined,
|
|
1629
|
+
calendarDeclared: drive.manifest.idleCapabilities?.calendar === true,
|
|
1629
1630
|
autoAcceptTasks: drive.context.autoAcceptTasks,
|
|
1630
1631
|
});
|
|
1631
1632
|
found.push(...own);
|
|
@@ -1798,6 +1799,38 @@ async function driveOneCall(drive) {
|
|
|
1798
1799
|
}
|
|
1799
1800
|
}
|
|
1800
1801
|
}
|
|
1802
|
+
// 4a. Schedule a follow-up, where the task offers it: applied says it is on the calendar, and the
|
|
1803
|
+
// calendar-updated that shows it is owed within the manifest's bound. The time is the provider's
|
|
1804
|
+
// own: a day after the instant it published the offer, so no clock of the runner's is read.
|
|
1805
|
+
if (latestTask().phase === "in-progress" && offers("schedule")) {
|
|
1806
|
+
const publishedAt = drive.events.slice(0, cursor).reverse().find(envelope => {
|
|
1807
|
+
const event = envelope.event;
|
|
1808
|
+
return (event.type === "task-offered" || event.type === "task-updated") && isTask(event.task) && event.task.assignmentId === taskId;
|
|
1809
|
+
})?.occurredAt;
|
|
1810
|
+
const at = new Date(Date.parse(String(publishedAt ?? "2026-01-01T00:00:00Z")) + 24 * 60 * 60 * 1000).toISOString();
|
|
1811
|
+
if (await send({ type: "schedule", at, note: "Follow-up promised on the call" }) !== undefined) {
|
|
1812
|
+
ruleEvaluated("drive.schedule.unsettled");
|
|
1813
|
+
const settle = Number(drive.manifest.settleMs);
|
|
1814
|
+
const carries = (calendar) => Array.isArray(calendar) && calendar.some(activity => isRecord(activity) && activity.startsAt === at);
|
|
1815
|
+
let unsettled = false;
|
|
1816
|
+
const shown = await waitFor("a calendar-updated carrying the follow-up", envelope => {
|
|
1817
|
+
const event = envelope.event;
|
|
1818
|
+
return event.type === "calendar-updated" && carries(event.calendar) ? event : undefined;
|
|
1819
|
+
}, cursor, { ms: settle, onExpiry: () => { unsettled = true; } });
|
|
1820
|
+
if (shown === undefined && unsettled) {
|
|
1821
|
+
let resync;
|
|
1822
|
+
try {
|
|
1823
|
+
resync = await drive.connection.snapshot();
|
|
1824
|
+
}
|
|
1825
|
+
catch (error) {
|
|
1826
|
+
refuse("drive.command.rejected", "drive.schedule", `snapshot() after an unsettled schedule rejected: ${String(error)}`);
|
|
1827
|
+
}
|
|
1828
|
+
if (!(isRecord(resync) && carries(resync.calendar))) {
|
|
1829
|
+
refuse("drive.schedule.unsettled", "drive.schedule", `schedule was applied and ${settle}ms later no calendar-updated carries a follow-up at ${at}, and a snapshot does not either: applied says the follow-up is on the calendar, and it is not`);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1801
1834
|
// 5. End the call, where the agent may -- with the microphone muted, as agents do. The leg left
|
|
1802
1835
|
// open at audio end is the provider's to close, since the provider knows the instant the audio
|
|
1803
1836
|
// ended; the host's own closing report can only follow what it hears, is answered recorded, and
|
|
@@ -1853,7 +1886,7 @@ async function driveOneCall(drive) {
|
|
|
1853
1886
|
// provider. A conversation has no audio to end and no completing phase to wait for: a chat or an
|
|
1854
1887
|
// email, and a voice task offering no end-call, is completed from where it stands.
|
|
1855
1888
|
const completable = latestTask().phase === "completing" || latestTask().phase === "in-progress" || latestTask().phase === "paused";
|
|
1856
|
-
if (latestTask().wrapAllowance
|
|
1889
|
+
if (!(latestTask().completionMode === "provider-automatic" && latestTask().wrapAllowance === 0) && completable) {
|
|
1857
1890
|
const command = { type: "complete" };
|
|
1858
1891
|
const outcomes = isRecord(latestTask().capabilities) ? latestTask().capabilities.outcomes : undefined;
|
|
1859
1892
|
if (isRecord(outcomes) && outcomes.required === true && Array.isArray(outcomes.codes) && isRecord(outcomes.codes[0])) {
|
|
@@ -1864,7 +1897,7 @@ async function driveOneCall(drive) {
|
|
|
1864
1897
|
// the manifest stated. Past it the host resyncs: a snapshot still carrying the task is a task
|
|
1865
1898
|
// held open by a provider that said it was done, and the desk shows it as unsettled.
|
|
1866
1899
|
ruleEvaluated("drive.completion.unsettled");
|
|
1867
|
-
const settle = Number(drive.manifest.
|
|
1900
|
+
const settle = Number(drive.manifest.settleMs);
|
|
1868
1901
|
let unsettled = false;
|
|
1869
1902
|
const end = await ended({ ms: settle, onExpiry: () => { unsettled = true; } });
|
|
1870
1903
|
if (end !== undefined)
|
|
@@ -2083,6 +2116,35 @@ export function stillHost(report = { online: true }, guarantees = {}, mute) {
|
|
|
2083
2116
|
return { guarantees, ...(mute === undefined ? {} : { mute }), report: () => report, subscribe: () => () => undefined };
|
|
2084
2117
|
}
|
|
2085
2118
|
const usableLogin = (status) => status === "authenticated" || status === "refreshing";
|
|
2119
|
+
/**
|
|
2120
|
+
* A break one provider forces stops the agent everywhere: the host states a capacity of zero on
|
|
2121
|
+
* every other usable provider holding capacity -- taken, never refused, and nothing the agent can
|
|
2122
|
+
* cancel -- and on the forcing provider states nothing, since the break itself holds the agent
|
|
2123
|
+
* there. A provider whose login is not usable is left alone, as a break attempt leaves it.
|
|
2124
|
+
*/
|
|
2125
|
+
export function assertForcedBreakStopsTheRest(forcedOn, candidates, stopped) {
|
|
2126
|
+
if (!candidates.some(candidate => candidate.id === forcedOn))
|
|
2127
|
+
throw new Error(`${forcedOn} is not a provider the host knows`);
|
|
2128
|
+
const chosen = new Set(stopped);
|
|
2129
|
+
for (const id of stopped) {
|
|
2130
|
+
if (!candidates.some(candidate => candidate.id === id))
|
|
2131
|
+
throw new Error(`${id} is not a provider the host knows`);
|
|
2132
|
+
}
|
|
2133
|
+
if (chosen.has(forcedOn))
|
|
2134
|
+
throw new Error(`${forcedOn} forced the break and holds the agent itself: its capacity is not restated`);
|
|
2135
|
+
for (const candidate of candidates) {
|
|
2136
|
+
if (candidate.id === forcedOn)
|
|
2137
|
+
continue;
|
|
2138
|
+
const expected = usableLogin(candidate.authentication) && candidate.holdsCapacity;
|
|
2139
|
+
if (expected && !chosen.has(candidate.id)) {
|
|
2140
|
+
throw new Error(`${candidate.id} can give the agent work and must be stopped: capacity 0 stated`);
|
|
2141
|
+
}
|
|
2142
|
+
if (!expected && chosen.has(candidate.id)) {
|
|
2143
|
+
const why = usableLogin(candidate.authentication) ? "holds no capacity" : `is ${candidate.authentication}`;
|
|
2144
|
+
throw new Error(`${candidate.id} ${why} and is not stopped: there is nothing to stop`);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2086
2148
|
/**
|
|
2087
2149
|
* A break attempt asks every connected provider from which the agent can currently receive work.
|
|
2088
2150
|
* A provider whose login is not usable -- `expired` above all -- is not asked, whatever else is
|
package/dist/validation.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface TaskValidationContext {
|
|
|
39
39
|
autoAcceptTasks?: boolean;
|
|
40
40
|
/** Whether the manifest declares `dialOutcomes`. A task that may dial needs it to; absent, the question is not asked. */
|
|
41
41
|
dialOutcomesDeclared?: boolean;
|
|
42
|
+
/** Whether the manifest declares the `calendar` idle capability. A task that may schedule needs it to; absent, the question is not asked. */
|
|
43
|
+
calendarDeclared?: boolean;
|
|
42
44
|
/**
|
|
43
45
|
* The values the queue locked on this login's tasks -- a party's number or email -- as whoever
|
|
44
46
|
* runs the validator knows them. Where a task's party stands locked, no other field of it may
|
package/dist/validation.js
CHANGED
|
@@ -91,7 +91,7 @@ const LISTENING_MODES = membersOf({ listen: true, coach: true, "join-call": true
|
|
|
91
91
|
const COMPLETED_BY = membersOf({ agent: true, provider: true });
|
|
92
92
|
const CANCELLED_BY = membersOf({ agent: true, provider: true, party: true });
|
|
93
93
|
const EXPIRABLE_PHASES = membersOf({
|
|
94
|
-
pending: true, confirmed: true,
|
|
94
|
+
pending: true, confirmed: true,
|
|
95
95
|
});
|
|
96
96
|
const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
|
|
97
97
|
/** The capabilities each channel arm of `TaskCapabilities` declares, keyed off the type itself. */
|
|
@@ -100,8 +100,8 @@ const TASK_CAPABILITIES = {
|
|
|
100
100
|
browsers: true, outcomes: true, custom: true, decline: true, hold: true,
|
|
101
101
|
endCall: true, terminateCall: true, connectBack: true, schedule: true, leadAssist: true, conference: true, recording: true,
|
|
102
102
|
}),
|
|
103
|
-
chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true }),
|
|
104
|
-
email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true }),
|
|
103
|
+
chat: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, hold: true, schedule: true }),
|
|
104
|
+
email: membersOf({ browsers: true, outcomes: true, custom: true, decline: true, schedule: true }),
|
|
105
105
|
};
|
|
106
106
|
// The published list and the type's keys are the same set, or one of them is wrong.
|
|
107
107
|
true;
|
|
@@ -393,6 +393,10 @@ export function validatePhone(value, manifest, path = "context.phone") {
|
|
|
393
393
|
function manifestDials(manifest) {
|
|
394
394
|
return isPlainObject(manifest) ? Array.isArray(manifest.dialOutcomes) : undefined;
|
|
395
395
|
}
|
|
396
|
+
/** Whether a manifest declares a calendar, and so may publish tasks that schedule onto it. `undefined` where there is no manifest to ask. */
|
|
397
|
+
function manifestCalendar(manifest) {
|
|
398
|
+
return isPlainObject(manifest) ? isPlainObject(manifest.idleCapabilities) && manifest.idleCapabilities.calendar === true : undefined;
|
|
399
|
+
}
|
|
396
400
|
/** Undefined explicitly means unavailable; a returned estimate is not a clock guarantee. */
|
|
397
401
|
export function validateProviderTimeEstimate(value, scope, path = "estimate") {
|
|
398
402
|
const into = new Collector();
|
|
@@ -520,8 +524,10 @@ export function validateManifest(manifest, path = "manifest") {
|
|
|
520
524
|
if (manifest.runningStepReports !== undefined) {
|
|
521
525
|
into.require(manifest.runningStepReports === true, "manifest.runningStepReports", `${path}.runningStepReports`, "runningStepReports is declared by presence, as true; a provider that takes begin and end only omits it");
|
|
522
526
|
}
|
|
523
|
-
|
|
524
|
-
|
|
527
|
+
for (const former of ["disposalSettleMs", "completionSettleMs"]) {
|
|
528
|
+
into.require(!Object.hasOwn(manifest, former), "manifest.settleMs.renamed", `${path}.${former}`, "use settleMs; the former field is not accepted");
|
|
529
|
+
}
|
|
530
|
+
into.require(typeof manifest.settleMs === "number" && Number.isInteger(manifest.settleMs) && manifest.settleMs > 0, "manifest.settleMs", `${path}.settleMs`, "settleMs is how long after applied the wire shows what the provider did -- the task-ended after a complete, the calendar-updated after a schedule -- a positive whole number of milliseconds, stated by every provider");
|
|
525
531
|
if (manifest.orgLevels !== undefined) {
|
|
526
532
|
if (!Array.isArray(manifest.orgLevels)) {
|
|
527
533
|
into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
|
|
@@ -1120,6 +1126,11 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1120
1126
|
into.require(context.channel === "voice", "task.phase.channel", `${path}.phase`, `a ${context.channel} task has no call to preview`);
|
|
1121
1127
|
into.require(context.dialOutcomesDeclared !== false, "task.preview.dialOutcomes.required", `${path}.phase`, "a preview ends in the agent pressing Call, which dials, and the manifest declares no dialOutcomes to say how a dial ends");
|
|
1122
1128
|
}
|
|
1129
|
+
// How long the offer has left travels on the pending task, so a reconnect snapshot restates it.
|
|
1130
|
+
if (task.expiresInSeconds !== undefined) {
|
|
1131
|
+
into.require(task.phase === "pending", "task.pending.expiresInSeconds.unexpected", `${path}.expiresInSeconds`, "expiresInSeconds is how long an offer has left, and belongs to a pending task; accepted, there is no offer to run out");
|
|
1132
|
+
into.require(isDurationSeconds(task.expiresInSeconds), "task.pending.expiresInSeconds", `${path}.expiresInSeconds`, "expiresInSeconds is how long the offer has left: a whole number of seconds, zero or more, counted from this publication");
|
|
1133
|
+
}
|
|
1123
1134
|
// The deadline and what happens at it travel together, and only while the record is being previewed.
|
|
1124
1135
|
if (task.previewEndsInSeconds !== undefined || task.atDeadline !== undefined) {
|
|
1125
1136
|
into.require(task.phase === "preview", "task.preview.deadline.unexpected", `${path}.previewEndsInSeconds`, "previewEndsInSeconds and atDeadline belong to a task in preview; past it there is nothing to wait for");
|
|
@@ -1239,8 +1250,10 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1239
1250
|
if (isPlainObject(capabilities.outcomes) && capabilities.outcomes.required === true) {
|
|
1240
1251
|
into.require(task.completionMode !== "provider-automatic", "task.outcomes.required.mode", `${path}.capabilities.outcomes.required`, "a required outcome is the agent's to give, so the task completes on the agent's command; provider-automatic completion cannot wait for it");
|
|
1241
1252
|
}
|
|
1253
|
+
// Under agent-command the allowance is the expected wrap, shown and never acted on: the agent
|
|
1254
|
+
// completes whenever they are done, so there is always a wrap to collect the code in.
|
|
1242
1255
|
if (capabilities.outcomes !== undefined) {
|
|
1243
|
-
into.require(task.wrapAllowance
|
|
1256
|
+
into.require(!(task.completionMode === "provider-automatic" && task.wrapAllowance === 0), "task.outcomes.wrapAllowance", `${path}.capabilities.outcomes`, "outcomes are collected in wrap, and provider-automatic with wrapAllowance 0 gives none: publish a wrap or no outcomes");
|
|
1244
1257
|
}
|
|
1245
1258
|
for (const [name, declared] of Object.entries(capabilities)) {
|
|
1246
1259
|
if (declared === undefined)
|
|
@@ -1256,6 +1269,10 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1256
1269
|
if (DIALLING_CAPABILITIES.includes(name) && context.dialOutcomesDeclared === false) {
|
|
1257
1270
|
into.add("task.capability.dialOutcomes.required", `${path}.capabilities.${name}`, `${name} dials, and the manifest declares no dialOutcomes to say how a dial ends`);
|
|
1258
1271
|
}
|
|
1272
|
+
// A follow-up lands on the calendar, so the manifest has to have one for it to land on.
|
|
1273
|
+
if (name === "schedule" && context.calendarDeclared === false) {
|
|
1274
|
+
into.add("task.capability.calendar.required", `${path}.capabilities.schedule`, "schedule puts a follow-up on the calendar, and the manifest declares no calendar for it to land on");
|
|
1275
|
+
}
|
|
1259
1276
|
// A control the queue could allow may stand locked in its place, saying whose. What the
|
|
1260
1277
|
// queue provides -- browsers, outcomes, custom controls -- is content, not a control.
|
|
1261
1278
|
if (isLocked(declared)) {
|
|
@@ -1399,8 +1416,11 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
|
|
|
1399
1416
|
else if (command.type === "force-break" || command.type === "end-forced-break") {
|
|
1400
1417
|
validateBreakState(context.memberBreak, `${path}.memberBreak`, into);
|
|
1401
1418
|
const state = isPlainObject(context.memberBreak) ? context.memberBreak : {};
|
|
1402
|
-
if (command.type === "end-forced-break")
|
|
1419
|
+
if (command.type === "end-forced-break") {
|
|
1403
1420
|
into.require(state.forced !== undefined && (state.status === "on-break" || state.status === "starting-after-task"), "team.command.endForcedBreak", path, "lift a current forced-break restriction without resuming the agent");
|
|
1421
|
+
// The platform lifts a break it imposed itself; a lead lifts one a lead asked for.
|
|
1422
|
+
into.require(!(isPlainObject(state.forced) && state.forced.by === "provider"), "team.command.endForcedBreak.provider", path, "the platform put this member on the break, and it lifts it: a lead ends a break a lead forced");
|
|
1423
|
+
}
|
|
1404
1424
|
if (command.type === "force-break") {
|
|
1405
1425
|
if (command.expectedDurationMs !== undefined)
|
|
1406
1426
|
into.require(typeof command.expectedDurationMs === "number" && Number.isFinite(command.expectedDurationMs) && command.expectedDurationMs > 0, "team.command.expectedDurationMs", `${path}.expectedDurationMs`, "expectedDurationMs must be positive finite milliseconds; it never resumes the agent");
|
|
@@ -1717,7 +1737,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
1717
1737
|
else {
|
|
1718
1738
|
const seen = new Set();
|
|
1719
1739
|
snapshot.tasks.forEach((task, index) => {
|
|
1720
|
-
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
|
|
1740
|
+
validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
|
|
1721
1741
|
if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
|
|
1722
1742
|
if (seen.has(task.assignmentId))
|
|
1723
1743
|
into.add("task.assignmentId.unique", `${path}.tasks[${index}].assignmentId`, `duplicate assignment id: ${task.assignmentId}`);
|
|
@@ -1899,16 +1919,15 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
|
|
|
1899
1919
|
validateBreakState(event.break, `${at}.break`, into);
|
|
1900
1920
|
break;
|
|
1901
1921
|
case "task-offered":
|
|
1902
|
-
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1922
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1903
1923
|
// An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
|
|
1904
1924
|
if (isPlainObject(event.task) && typeof event.task.phase === "string") {
|
|
1905
1925
|
into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
|
|
1906
1926
|
}
|
|
1907
|
-
|
|
1908
|
-
into.require(isDurationSeconds(event.expiresInSeconds), "event.taskOffered.expiresInSeconds", `${at}.expiresInSeconds`, "expiresInSeconds is how long the offer stands: a whole number of seconds, zero or more, counted from this event");
|
|
1927
|
+
into.require(event.expiresInSeconds === undefined, "event.taskOffered.expiresInSeconds.unexpected", `${at}.expiresInSeconds`, "expiresInSeconds travels on the pending task, where a reconnect snapshot restates it, not on the event");
|
|
1909
1928
|
break;
|
|
1910
1929
|
case "task-updated":
|
|
1911
|
-
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1930
|
+
validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), calendarDeclared: manifestCalendar(manifest), locked: context.locked }, `${at}.task`, into);
|
|
1912
1931
|
break;
|
|
1913
1932
|
case "task-audio-started":
|
|
1914
1933
|
into.require(channel === "voice", "event.audio.channel", `${at}.type`, "only a voice provider publishes audio transitions");
|
|
@@ -2543,7 +2562,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2543
2562
|
case "complete": {
|
|
2544
2563
|
// The agent may finish early under either mode; the provider is free to end at once. With
|
|
2545
2564
|
// no wrap at all there is no window to cut short, and nothing for the command to do.
|
|
2546
|
-
into.require(task.wrapAllowance
|
|
2565
|
+
into.require(!(task.completionMode === "provider-automatic" && task.wrapAllowance === 0), "command.complete.wrapAllowance", path, "the task has no wrap: under provider-automatic with wrapAllowance 0 the provider ends it itself, and there is nothing to complete");
|
|
2547
2566
|
// What travels with complete is what the outcomes capability published: a code from its
|
|
2548
2567
|
// list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
|
|
2549
2568
|
const outcomes = capabilities.outcomes;
|
package/guide.md
CHANGED
|
@@ -203,7 +203,7 @@ type Manifest<C extends Channel = Channel> = {
|
|
|
203
203
|
dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
|
|
204
204
|
phones?: C extends "voice" ? Phone[] : never;
|
|
205
205
|
runningStepReports?: true;
|
|
206
|
-
|
|
206
|
+
settleMs: number;
|
|
207
207
|
};
|
|
208
208
|
```
|
|
209
209
|
|
|
@@ -569,8 +569,8 @@ type TaskCapabilities<C extends Channel = Channel> =
|
|
|
569
569
|
recording?: Lockable<TaskRecordingPolicy>;
|
|
570
570
|
}
|
|
571
571
|
: C extends "chat"
|
|
572
|
-
? SharedTaskCapabilities & { decline?: Lockable<true>; hold?: Lockable<true> }
|
|
573
|
-
: SharedTaskCapabilities & { decline?: Lockable<true> };
|
|
572
|
+
? SharedTaskCapabilities & { decline?: Lockable<true>; hold?: Lockable<true>; schedule?: Lockable<true> }
|
|
573
|
+
: SharedTaskCapabilities & { decline?: Lockable<true>; schedule?: Lockable<true> };
|
|
574
574
|
```
|
|
575
575
|
|
|
576
576
|
The channel arms are why `Task<"email">` rejects `hold` at compile time rather than at runtime.
|
|
@@ -723,6 +723,7 @@ type Task<C extends Channel = Channel> = {
|
|
|
723
723
|
party?: Contact;
|
|
724
724
|
phase: TaskPhase;
|
|
725
725
|
acceptance?: AcceptanceMode;
|
|
726
|
+
expiresInSeconds?: DurationSeconds;
|
|
726
727
|
previewEndsInSeconds?: DurationSeconds;
|
|
727
728
|
atDeadline?: PreviewDeadline;
|
|
728
729
|
reference?: string;
|
|
@@ -745,7 +746,7 @@ type TaskOutcome =
|
|
|
745
746
|
| { type: "completed"; by: "agent" | "provider" }
|
|
746
747
|
| { type: "taken-over"; leadId: UserId }
|
|
747
748
|
| { type: "cancelled"; by: "agent" | "provider" | "party"; reason?: string }
|
|
748
|
-
| { type: "expired"; phase: "pending" | "confirmed"
|
|
749
|
+
| { type: "expired"; phase: "pending" | "confirmed" }
|
|
749
750
|
| { type: "failed"; failure: ProtocolFailure };
|
|
750
751
|
```
|
|
751
752
|
|
|
@@ -768,8 +769,8 @@ const TASK_COMMAND_NAMES = {
|
|
|
768
769
|
"schedule",
|
|
769
770
|
"complete",
|
|
770
771
|
],
|
|
771
|
-
chat: ["accept", "decline", "pause", "resume", "complete"],
|
|
772
|
-
email: ["accept", "decline", "complete"],
|
|
772
|
+
chat: ["accept", "decline", "pause", "resume", "schedule", "complete"],
|
|
773
|
+
email: ["accept", "decline", "schedule", "complete"],
|
|
773
774
|
} as const;
|
|
774
775
|
|
|
775
776
|
type TaskCommandName<C extends keyof typeof TASK_COMMAND_NAMES> =
|
|
@@ -786,7 +787,7 @@ type VoiceTaskCommand =
|
|
|
786
787
|
| { type: "end-call" }
|
|
787
788
|
| { type: "terminate-call" }
|
|
788
789
|
| { type: "connect-back"; dialId: DialId }
|
|
789
|
-
|
|
|
790
|
+
| ScheduleCommand
|
|
790
791
|
| { type: "lead-assist"; action: "request"; note?: string }
|
|
791
792
|
| { type: "lead-assist"; action: "cancel" }
|
|
792
793
|
| { type: "conference"; action: "add"; dialId: DialId; destinationId: string }
|
|
@@ -795,16 +796,20 @@ type VoiceTaskCommand =
|
|
|
795
796
|
| (RecordingCommand & { source: "provider" })
|
|
796
797
|
| ({ type: "complete" } & OutcomePayload);
|
|
797
798
|
|
|
799
|
+
type ScheduleCommand = { type: "schedule"; at: IsoTimestamp; note?: string };
|
|
800
|
+
|
|
798
801
|
type ChatTaskCommand =
|
|
799
802
|
| { type: "accept" }
|
|
800
803
|
| { type: "decline" }
|
|
801
804
|
| { type: "pause" }
|
|
802
805
|
| { type: "resume" }
|
|
806
|
+
| ScheduleCommand
|
|
803
807
|
| ({ type: "complete" } & OutcomePayload);
|
|
804
808
|
|
|
805
809
|
type EmailTaskCommand =
|
|
806
810
|
| { type: "accept" }
|
|
807
811
|
| { type: "decline" }
|
|
812
|
+
| ScheduleCommand
|
|
808
813
|
| ({ type: "complete" } & OutcomePayload);
|
|
809
814
|
|
|
810
815
|
type CustomTaskCommand = { type: "custom"; name: string; [key: string]: unknown };
|
|
@@ -1115,7 +1120,6 @@ type ProviderEvent =
|
|
|
1115
1120
|
| {
|
|
1116
1121
|
type: "task-offered";
|
|
1117
1122
|
task: Task;
|
|
1118
|
-
expiresInSeconds?: DurationSeconds;
|
|
1119
1123
|
}
|
|
1120
1124
|
| { type: "task-updated"; task: Task }
|
|
1121
1125
|
| { type: "task-audio-started"; assignmentId: AssignmentId }
|
|
@@ -1254,6 +1258,7 @@ const OMNI_FAILURE_CODES = [
|
|
|
1254
1258
|
"omni.unavailable",
|
|
1255
1259
|
"omni.break-already-committed",
|
|
1256
1260
|
"omni.recording-unsettled",
|
|
1261
|
+
"omni.break-forced-by-provider",
|
|
1257
1262
|
] as const;
|
|
1258
1263
|
type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
|
|
1259
1264
|
```
|
|
@@ -1586,7 +1591,7 @@ export default defineAdapter({
|
|
|
1586
1591
|
channel: "voice",
|
|
1587
1592
|
supportedProtocolVersions: [OMNI_PROTOCOL_VERSION],
|
|
1588
1593
|
authenticationMethods: ["browser-sso"],
|
|
1589
|
-
|
|
1594
|
+
settleMs: 5000,
|
|
1590
1595
|
idleCapabilities: {
|
|
1591
1596
|
dial: { destinations: "any-number" },
|
|
1592
1597
|
},
|
|
@@ -1624,7 +1629,7 @@ compile time.
|
|
|
1624
1629
|
| `timeCheck` | Optional `true`: implements `checkTime` for fresh provider-clock samples; agent application polling is independently opt-in. |
|
|
1625
1630
|
| `timestampAuthority` | Optional `"provider"`: provider timestamps are final; agent application instants are advisory. Omission makes no trust promise. |
|
|
1626
1631
|
| `runningStepReports` | The provider takes running reports of an agent application-performed step — `recordStep` with `seconds` so far and no `ended`. Omitted, the agent application sends exactly two reports per leg, when it began and when it ended, and a running one is refused. See **The agent application records what it performs**. |
|
|
1627
|
-
| `
|
|
1632
|
+
| `settleMs` | Required. How long after `applied` the wire shows what the provider did: the `task-ended` after a `complete`, the `calendar-updated` after a `schedule`. A positive whole number of milliseconds (`manifest.settleMs`). Stated per provider, since platforms settle at different speeds. See **`task-ended`**. |
|
|
1628
1633
|
|
|
1629
1634
|
### Authentication methods
|
|
1630
1635
|
|
|
@@ -2318,8 +2323,7 @@ declare const task: Task;
|
|
|
2318
2323
|
|
|
2319
2324
|
const assignment = {
|
|
2320
2325
|
type: "task-offered",
|
|
2321
|
-
task: { ...task, phase: "pending", acceptance: "consent" },
|
|
2322
|
-
expiresInSeconds: 30,
|
|
2326
|
+
task: { ...task, phase: "pending", acceptance: "consent", expiresInSeconds: 30 },
|
|
2323
2327
|
} satisfies Extract<ProviderEvent, { type: "task-offered" }>;
|
|
2324
2328
|
```
|
|
2325
2329
|
|
|
@@ -2337,10 +2341,12 @@ on a desk phone — and is not in question per call. See **How the agent hears t
|
|
|
2337
2341
|
|
|
2338
2342
|
Automatic acceptance still begins with `task-offered`.
|
|
2339
2343
|
|
|
2340
|
-
`expiresInSeconds` is how long the offer
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
+
`expiresInSeconds` is how long the offer has left, in whole seconds from the publication that
|
|
2345
|
+
carries it. It rides on the pending task, as `acceptance` does, so a reconnect snapshot restates
|
|
2346
|
+
it from what is actually left; it belongs to `pending` alone and is gone once the task is accepted
|
|
2347
|
+
(`task.pending.expiresInSeconds`, `.unexpected`). Where present, Omni counts down from receipt and
|
|
2348
|
+
stops offering **Accept** once it runs out; the provider ends the lapsed offer with `task-ended`
|
|
2349
|
+
and an `expired` outcome naming `pending`, since nobody cancelled it. **Omit it unless the provider can observe it.** A provider that reports only elapsed
|
|
2344
2350
|
ring time after the fact cannot say how long an offer has left, and a computed value would have
|
|
2345
2351
|
Omni withdraw **Accept** from a task still pending.
|
|
2346
2352
|
|
|
@@ -2349,7 +2355,7 @@ different clocks, and a countdown that compares one against the other is a count
|
|
|
2349
2355
|
fast or slow by their skew. Seconds from the publication that carries them are the provider's own
|
|
2350
2356
|
arithmetic, counted down on the desk from the moment they arrive, and the transport delay is the
|
|
2351
2357
|
only error. Restated on every publication that carries them, so a snapshot after a reconnect
|
|
2352
|
-
starts the countdown afresh from what is actually left (`
|
|
2358
|
+
starts the countdown afresh from what is actually left (`task.pending.expiresInSeconds`,
|
|
2353
2359
|
`task.preview.previewEndsInSeconds`: a whole number, zero or more).
|
|
2354
2360
|
|
|
2355
2361
|
A preview's deadline is not on the offer. It travels on the task, as `previewEndsInSeconds` with
|
|
@@ -2396,6 +2402,7 @@ time. Runtime conformance checks also require the task channel to match its prov
|
|
|
2396
2402
|
| `phase` | Current canonical task phase: `pending`, `confirmed`, `preview`, `in-progress`, `paused`, or `completing`. `preview` is voice only. |
|
|
2397
2403
|
| `audio` | Voice only. The task's real-time audio as the provider holds it: `started` while audio is attached, `ended` once it ended, omitted while none is. The provider's word — see **`task-audio-started`**. Audio names a task whose work has begun, or whose party the agent application is dialling: on a `pending`, `confirmed` or `preview` task with nobody ringing it is refused (`task.audio.beforeWork`), on a snapshot as on the event, since an agent application opens the microphone on it; with the party ringing by an agent application dial or provider-triggered preview dial, actual ring-back audio may precede answer. |
|
|
2398
2404
|
| `acceptance` | How this offer is accepted — `no-preference`, `consent`, or `automatic` — stated on the pending task so a reconnect snapshot says it too. Required while `pending` when `autoAcceptTasks` was `true`, forbidden when it was `false`, and absent past `pending`. See **Acceptance modes**. |
|
|
2405
|
+
| `expiresInSeconds` | In `pending` only: how long the offer has left, in whole seconds from this publication, restated on every publication that carries it. Absent, the offer stands until the provider says otherwise. See **Pending**. |
|
|
2399
2406
|
| `previewEndsInSeconds` | Voice only, in `preview`: how long the preparation has left, in whole seconds from this publication, restated on every publication that carries it. Absent, the agent has as long as they need without a preparation countdown. Always with `atDeadline`. See **Preview: the agent presses Call**. |
|
|
2400
2407
|
| `atDeadline` | Voice only, in `preview`, with `previewEndsInSeconds`: what the system does when it runs out -- `provider-dials` makes the provider initiate dialing, `host-dials` makes the agent application issue Call, `waits` keeps the task in preview awaiting the agent. |
|
|
2401
2408
|
| `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
|
|
@@ -2618,18 +2625,26 @@ provider may complete the task without receiving that command.
|
|
|
2618
2625
|
**The agent may finish early under either mode.** `complete` is issuable wherever there is a wrap
|
|
2619
2626
|
to cut short: under `agent-command` it is the end, and under `provider-automatic` it says the agent
|
|
2620
2627
|
is done before the allowance ran out, and the provider is free to end the task at once. Only a task
|
|
2621
|
-
with no wrap at all -- `wrapAllowance: 0` -- has nothing to complete, and
|
|
2622
|
-
there (`command.complete.wrapAllowance`). An ending the agent asked for says
|
|
2623
|
-
`by: "agent"`, whichever mode the task was under.
|
|
2628
|
+
with no wrap at all -- `provider-automatic` with `wrapAllowance: 0` -- has nothing to complete, and
|
|
2629
|
+
the command is refused there (`command.complete.wrapAllowance`). An ending the agent asked for says
|
|
2630
|
+
so, `completed` with `by: "agent"`, whichever mode the task was under.
|
|
2624
2631
|
|
|
2625
2632
|
**A required outcome is the agent's to give.** A task whose `outcomes` says `required: true` waits
|
|
2626
2633
|
for a code the agent chooses, so it completes on the agent's command: `provider-automatic` cannot
|
|
2627
2634
|
wait for it, and the pair is refused (`task.outcomes.required.mode`). Optional outcomes may ride on
|
|
2628
2635
|
a task the provider completes itself; the agent gives one if in time. And a code is given in wrap,
|
|
2629
|
-
so a task with `wrapAllowance: 0` publishes no `outcomes` at all
|
|
2636
|
+
so a `provider-automatic` task with `wrapAllowance: 0` publishes no `outcomes` at all
|
|
2637
|
+
(`task.outcomes.wrapAllowance`).
|
|
2638
|
+
|
|
2639
|
+
**The allowance means one thing per mode.** Under `provider-automatic` it is what the provider
|
|
2640
|
+
acts on: the task ends when it runs out. Under `agent-command` it is the expected wrap, stated so
|
|
2641
|
+
the agent can see it: the desk counts it down and, past zero, shows how far over they are, and
|
|
2642
|
+
nothing acts on it -- the task waits for `complete` however long that takes, with its outcomes
|
|
2643
|
+
collected whenever the agent gives them. A provider that would end the task at a time is
|
|
2644
|
+
`provider-automatic`.
|
|
2630
2645
|
|
|
2631
|
-
`wrapAllowance` is
|
|
2632
|
-
|
|
2646
|
+
`wrapAllowance` is fixed, and when it starts depends on whether the channel carries real-time
|
|
2647
|
+
audio:
|
|
2633
2648
|
|
|
2634
2649
|
| Channel | Wrap allowance starts at |
|
|
2635
2650
|
| --- | --- |
|
|
@@ -2658,9 +2673,9 @@ const emailCompletion = {
|
|
|
2658
2673
|
In this example, the agent has two minutes after sending the email to add notes, select a
|
|
2659
2674
|
outcome, and complete the task.
|
|
2660
2675
|
|
|
2661
|
-
`0` means
|
|
2662
|
-
|
|
2663
|
-
|
|
2676
|
+
`0` means no wrap under `provider-automatic`: the provider completes the task at the end of the
|
|
2677
|
+
interaction, and `complete` has nothing to cut short. Under `agent-command` it is an expected wrap
|
|
2678
|
+
of nothing -- the desk shows the overrun from the start -- and the task still waits for `complete`.
|
|
2664
2679
|
|
|
2665
2680
|
There is no value meaning "unlimited", because a number that is not a duration would be read as
|
|
2666
2681
|
one. A provider that imposes no deadline says so by **omitting** `wrapAllowance`, which
|
|
@@ -2748,7 +2763,7 @@ The record is the call's, not the task's: most of what it holds happened before
|
|
|
2748
2763
|
*Interaction* keeps the one meaning **Terms** gives it, the agent's time on the call, which is why
|
|
2749
2764
|
the total of earlier agents' time is `interactionSeconds` and the phase rules are
|
|
2750
2765
|
`command.phase.interaction`. Hosts and providers adopt these names together; no legacy aliases
|
|
2751
|
-
are provided. The manifest's `
|
|
2766
|
+
are provided. The manifest's `settleMs` bounds final task completion, not entry into
|
|
2752
2767
|
the `completing` wrap-up phase. The `recordStep` method and `complete` command keep their names.
|
|
2753
2768
|
|
|
2754
2769
|
Migration from the earlier spellings:
|
|
@@ -2765,7 +2780,7 @@ Migration from the earlier spellings:
|
|
|
2765
2780
|
| Task.allocationId, AllocationId, allocationExpiresAt | `Task.assignmentId`, `AssignmentId`, `expiresInSeconds` |
|
|
2766
2781
|
| Task.id, TaskId, and `taskId` on every event, command, report and lead request | gone: a task is named by `assignmentId` alone, and `assignmentKey(providerId, assignmentId)` scopes it |
|
|
2767
2782
|
| taskKey, omni.task-not-found, PROVIDER_NAME__TASK_ID__TAB_NAME (ProviderName.TaskId.TabName) | `assignmentKey`, `omni.assignment-not-found`, `PROVIDER_NAME__ASSIGNMENT_ID__TAB_NAME` (`ProviderName.AssignmentId.TabName`) |
|
|
2768
|
-
| Manifest.disposalSettleMs | `Manifest.
|
|
2783
|
+
| Manifest.disposalSettleMs, Manifest.completionSettleMs | `Manifest.settleMs` (`manifest.settleMs`, `manifest.settleMs.renamed`): the bound after `applied` for `complete` and `schedule` alike |
|
|
2769
2784
|
| the call command, atDeadline calls and host-calls | `dial`, `"provider-dials"`, `"host-dials"` |
|
|
2770
2785
|
| media: task-media-started/-ended, team-media-*, Task.media, TaskMediaState, openMedia, OpenMediaRequest/Result, VoiceMediaSession and its session field | audio: `task-audio-started`, `task-audio-ended`, `audio` on the task, `TaskAudioState`, `openAudio`, `OpenAudioRequest`, `OpenAudioResult`, `CallAudio` on the result's `audio`; there is no team audio, the lead's follows `listening` on the member |
|
|
2771
2786
|
| capabilitySource values ungoverned and undetermined | `nobody`, `not-yet-read` (`capabilitySource.notYetRead`) |
|
|
@@ -2777,7 +2792,12 @@ Migration from the earlier spellings:
|
|
|
2777
2792
|
| TeamMembers.requests, LeadRequest | `TeamMember.request` (`MemberRequest`): the ask rides on the member (`team.member.request.*`) |
|
|
2778
2793
|
| team-updated on every change | `team-updated` whole once after the switch and on snapshots; `team-member-updated`, `team-member-removed`, `team-policies-updated` after (`stream.team.baseline`, `stream.teamMember.unknown`) |
|
|
2779
2794
|
| RecordingCommand.requestId | gone: no request identity; a partial effect is a settled `failed` under `omni.recording-unsettled` |
|
|
2780
|
-
| command.complete.mode | `command.complete.wrapAllowance`: `complete` under either mode, refused only with no wrap |
|
|
2795
|
+
| command.complete.mode | `command.complete.wrapAllowance`: `complete` under either mode, refused only on `provider-automatic` with no wrap |
|
|
2796
|
+
| expiresInSeconds on task-offered | `Task.expiresInSeconds`, in `pending` (`task.pending.expiresInSeconds`, `.unexpected`; `event.taskOffered.expiresInSeconds.unexpected`) |
|
|
2797
|
+
| the expired outcome naming preview | gone: nothing expires a preview; withdrawn, it is `cancelled` by the provider |
|
|
2798
|
+
| schedule on voice alone | `schedule` on every channel, under a manifest that declares `calendar` (`task.capability.calendar.required`), bounded by `settleMs` (`drive.schedule.unsettled`) |
|
|
2799
|
+
| a forced break requesting breaks on the other providers | `setCapacity({ count: 0 })` on every other usable provider (`assertForcedBreakStopsTheRest`) |
|
|
2800
|
+
| end-forced-break on a break the platform imposed | refused: `team.command.endForcedBreak.provider`, `omni.break-forced-by-provider` |
|
|
2781
2801
|
|
|
2782
2802
|
Update producers, consumers, saved task snapshots, and validation-rule assertions together.
|
|
2783
2803
|
History and report rule names use `history` and `historyReport`; assignment rules use
|
|
@@ -3377,28 +3397,39 @@ caller to somebody else.
|
|
|
3377
3397
|
|
|
3378
3398
|
An agent on a call promises to call back on Thursday, or in wrap writes up that the customer wants
|
|
3379
3399
|
a callback once the refund lands. The follow-up goes on the calendar, and the platform owns the
|
|
3380
|
-
calendar: `schedule` asks the provider to put it there.
|
|
3400
|
+
calendar: `schedule` asks the provider to put it there. A chat or an email agent promises a
|
|
3401
|
+
callback as often, so the control is on every channel.
|
|
3381
3402
|
|
|
3382
3403
|
```ts
|
|
3383
|
-
// From the call, or from its wrap: the time, and a note where the agent wrote one.
|
|
3404
|
+
// From the call or the conversation, or from its wrap: the time, and a note where the agent wrote one.
|
|
3384
3405
|
{ type: "schedule", at: "2026-08-28T10:00:00Z", note: "Call back about the refund" }
|
|
3385
3406
|
```
|
|
3386
3407
|
|
|
3387
3408
|
`schedule` is gated by the `schedule` capability and issuable in `in-progress`, `paused` or
|
|
3388
3409
|
`completing` (`command.phase.interaction`): a follow-up is promised on the call and written up in
|
|
3389
|
-
wrap alike, and the task names the party it is for.
|
|
3390
|
-
|
|
3391
|
-
|
|
3410
|
+
wrap alike, and the task names the party it is for. It lands on the calendar, so a task may offer
|
|
3411
|
+
it only under a manifest that declares the `calendar` idle capability
|
|
3412
|
+
(`task.capability.calendar.required`), as a control that dials needs `dialOutcomes`; a locked
|
|
3413
|
+
`schedule` is still a declared one. The command carries a time and a note and nothing else
|
|
3392
3414
|
(`command.schedule.at`, `command.schedule.note`, `command.field`): what the platform makes of a
|
|
3393
3415
|
follow-up -- a campaign record, a reminder, a scheduled dial -- is its own, and the agent sees it
|
|
3394
3416
|
on the calendar.
|
|
3395
3417
|
|
|
3418
|
+
**`applied` says the follow-up is on the calendar**, and the `calendar-updated` that shows it
|
|
3419
|
+
follows within the manifest's `settleMs`, as a `task-ended` follows an applied `complete`. Past
|
|
3420
|
+
the bound the agent application calls `snapshot()`: a calendar carrying an activity at that instant
|
|
3421
|
+
clears the wait; one without it shows the follow-up as unsettled -- "Scheduled... the provider has
|
|
3422
|
+
not confirmed" -- naming the command. The drive schedules one follow-up where the task offers it,
|
|
3423
|
+
at a time it takes from the provider's own publication, and holds the provider to the same bound
|
|
3424
|
+
(`drive.schedule.unsettled`).
|
|
3425
|
+
|
|
3396
3426
|
### Chat capabilities
|
|
3397
3427
|
|
|
3398
3428
|
| Capability | Omni UI | Contract |
|
|
3399
3429
|
| --- | --- | --- |
|
|
3400
3430
|
| `decline` | Pending-task button: Decline | The provider can decline a pending chat offer. Omni shows it only when local policy also permits declining. |
|
|
3401
3431
|
| `hold` | Primary toggle: Hold | Omni may pause and resume the agent’s interaction in the chat. |
|
|
3432
|
+
| `schedule` | Secondary menu item: Schedule | Omni may put a follow-up for this party on the calendar, from the conversation or from its wrap. See **Scheduling a follow-up**. |
|
|
3402
3433
|
| `outcomes` | Primary button: Complete | Omni may request task completion with a provider outcome and notes. |
|
|
3403
3434
|
|
|
3404
3435
|
### Email capabilities
|
|
@@ -3406,6 +3437,7 @@ on the calendar.
|
|
|
3406
3437
|
| Capability | Omni UI | Contract |
|
|
3407
3438
|
| --- | --- | --- |
|
|
3408
3439
|
| `decline` | Pending-task button: Decline | The provider can decline a pending email offer. Omni shows it only when local policy also permits declining. |
|
|
3440
|
+
| `schedule` | Secondary menu item: Schedule | Omni may put a follow-up for this party on the calendar, from the message or from its wrap. See **Scheduling a follow-up**. |
|
|
3409
3441
|
| `outcomes` | Primary button: Complete | Omni may request task completion with a provider outcome and notes. |
|
|
3410
3442
|
|
|
3411
3443
|
### Custom capabilities
|
|
@@ -3748,6 +3780,14 @@ entry into `on-break`, excluding any `starting-after-task` wait. Omission means
|
|
|
3748
3780
|
duration was supplied. Neither duration expiry nor an overdue indication authorizes the agent application
|
|
3749
3781
|
or provider to end the break, restore availability, or route work to the agent.
|
|
3750
3782
|
|
|
3783
|
+
**A forced break is the provider's act.** A lead's `force-break` is a command to the provider,
|
|
3784
|
+
which forces the break on the member; `by` names the lead who asked it to. A break the platform
|
|
3785
|
+
imposed on its own -- a schedule, a compliance hold -- says `by: "provider"`, and is the platform's
|
|
3786
|
+
to lift: a lead's `end-forced-break` on it is refused before it is sent
|
|
3787
|
+
(`team.command.endForcedBreak.provider`), and a provider that receives one answers `failed` with
|
|
3788
|
+
`omni.break-forced-by-provider`. The desk offers End forced break on a member only where
|
|
3789
|
+
`forced.by` names a lead.
|
|
3790
|
+
|
|
3751
3791
|
**A lead lifting the restriction does not resume the agent.** On an applied
|
|
3752
3792
|
`end-forced-break`, the provider clears `BreakState.forced` while preserving the current
|
|
3753
3793
|
`on-break` or `starting-after-task` status and `activeReasonId`. This command cannot publish
|
|
@@ -3766,14 +3806,21 @@ An agent application may display the expected duration. A countdown requires an
|
|
|
3766
3806
|
a received snapshot, replay or reconnect is not a new start and cannot restart the duration.
|
|
3767
3807
|
Without that evidence, show the duration without inventing a start or return time.
|
|
3768
3808
|
|
|
3769
|
-
A break applies to the **agent**, not to one provider. When a provider forces one, Omni
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
the agent
|
|
3776
|
-
|
|
3809
|
+
A break applies to the **agent**, not to one provider. When a provider forces one, Omni stops the
|
|
3810
|
+
agent everywhere else at once, and not by asking: it states `setCapacity({ count: 0 })` on every
|
|
3811
|
+
other usable provider holding capacity, which is taken and never refused, and which their team
|
|
3812
|
+
lists show as `elsewhere`. Nobody on those providers can deny it, and the agent cannot cancel it,
|
|
3813
|
+
because there is nothing to cancel: a break request on them would be the agent's own to withdraw
|
|
3814
|
+
and a lead's to refuse, and a forced break is neither. When the forcing provider ends the forced
|
|
3815
|
+
break and the agent resumes there, the agent application restates its capacity on the rest. A
|
|
3816
|
+
provider whose login is not usable is left alone, as a break attempt leaves it;
|
|
3817
|
+
`assertForcedBreakStopsTheRest` holds an agent application to this set. **The provider keeps its
|
|
3818
|
+
shift totals true.** A member held elsewhere is one the provider sees -- `elsewhere` is a state it
|
|
3819
|
+
publishes -- and the day's `shift` is its own account of the day, so the time the agent was
|
|
3820
|
+
stopped for a break forced on another provider is the provider's to count, as a break of a kind
|
|
3821
|
+
its platform has, or introduces, for exactly this. A `breakSeconds` that leaves it out is a total
|
|
3822
|
+
the provider knew to be short, and **Never report a value you cannot observe** cuts the other way
|
|
3823
|
+
here: the provider observed it.
|
|
3777
3824
|
|
|
3778
3825
|
`ForcedBreak.by` says who forced it: a lead, by user id, or `provider` where the platform itself
|
|
3779
3826
|
did -- on its own rule, a schedule, a compliance hold. The agent sees who, and `getUserDetails()`
|
|
@@ -4241,7 +4288,7 @@ assignment from the member alone.
|
|
|
4241
4288
|
| `{ type: "decide-break-request", memberId, decision, reason? }` | Settles one pending request. `decision` is `granted` or `denied`. A grant moves the member to `granted`; a denial ends the request and moves it directly to `not-requested`. |
|
|
4242
4289
|
| `{ type: "set-break-policy", policy }` | `approval-required`, `automatically-approved`, or `requests-suspended`. |
|
|
4243
4290
|
| `{ type: "force-break", memberId, reasonId?, reason?, expectedDurationMs? }` | Puts a member on a break they did not ask for. `reasonId` names a published `BreakReason.id` and is required whenever the provider publishes `reasons`; the member's forced break carries it as `activeReasonId`, so its kind is known. Optional `expectedDurationMs` is advisory and is published on the resulting forced break. |
|
|
4244
|
-
| `{ type: "end-forced-break", memberId }` |
|
|
4291
|
+
| `{ type: "end-forced-break", memberId }` | Asks the provider to lift a forced-break restriction a lead asked for, by clearing `BreakState.forced`; one the platform imposed is its own to lift (`team.command.endForcedBreak.provider`, `omni.break-forced-by-provider`). The committed break continues; only the agent resumes work. |
|
|
4245
4292
|
| `{ type: "join", memberId, assignmentId? }` | Answers the member's request: the provider bridges the lead's channel into the call. See **Lead assist**. |
|
|
4246
4293
|
| `{ type: "decline", memberId, assignmentId?, reason? }` | Refuses the member's request. |
|
|
4247
4294
|
| `{ type: "listen", memberId, assignmentId? }` | The lead's channel joins the member's call unasked, in silence. See **Listening to a call**. |
|
|
@@ -4756,9 +4803,9 @@ declared:
|
|
|
4756
4803
|
| `conference` with `action: "remove"` | The `conference` capability, and somebody else on the call: a remove that would leave the agent alone is `end-call`, and a provider answers it `failed`. |
|
|
4757
4804
|
| `decline` | The `decline` capability on any channel, **and** Omni local policy permitting it. One word for refusing an offer, whatever the channel. |
|
|
4758
4805
|
| `dial` | The `preview` phase. A record put in front of an agent is there to be called, so the phase is the gate and there is no capability. It is a dial, with a `dialId` and a `dial-outcome`. |
|
|
4759
|
-
| `complete` | A wrap to cut short: any task but one with `wrapAllowance: 0`, under either completion mode (`command.complete.wrapAllowance`). The `outcomes` capability decides whether a code travels with the command, never whether the command exists — a task Omni cannot complete never ends. What travels is what the capability published: a code from its list where it has one (`command.complete.outcome.unknown`), a code at all where it requires one (`.outcome.required`), notes as it said (`.notes.required`, `.notes.unexpected`), and neither where the task declares no outcomes (`.outcome.unexpected`). |
|
|
4806
|
+
| `complete` | A wrap to cut short: any task but a `provider-automatic` one with `wrapAllowance: 0`, under either completion mode (`command.complete.wrapAllowance`). The `outcomes` capability decides whether a code travels with the command, never whether the command exists — a task Omni cannot complete never ends. What travels is what the capability published: a code from its list where it has one (`command.complete.outcome.unknown`), a code at all where it requires one (`.outcome.required`), notes as it said (`.notes.required`, `.notes.unexpected`), and neither where the task declares no outcomes (`.outcome.unexpected`). |
|
|
4760
4807
|
| `connect-back` | The `connectBack` 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. |
|
|
4761
|
-
| `schedule` | The `schedule` capability, in `in-progress`, `paused` or `completing`: a follow-up is promised on the call and written up in wrap alike. |
|
|
4808
|
+
| `schedule` | The `schedule` capability, on any channel, in `in-progress`, `paused` or `completing`: a follow-up is promised on the call and written up in wrap alike. The manifest declares `calendar` for it to land on. |
|
|
4762
4809
|
| `lead-assist` with `action: "request"` or `"cancel"` | The `leadAssist` capability. `cancel` needs a request standing -- `Task.leadAssist` with status `requested`. |
|
|
4763
4810
|
| `conference` with `action: "add"` | Its capability, and a `destinationId` the directory offered: the id Omni sends is the id the provider published (`command.destination.unknown`). |
|
|
4764
4811
|
| `custom` | A control the task published under `capabilities.custom`, by its `id` (`command.capability.custom`), carrying a non-empty string for each `required` prompt field and strings for any optional fields supplied (`command.custom.prompt`). A toggle carries its target `on` boolean (`command.custom.on`). |
|
|
@@ -5170,7 +5217,7 @@ take-over as it follows an `end-call` -- see **Lead assist**.
|
|
|
5170
5217
|
A successful `complete` command does not clear the task. Omni waits for `task-ended`,
|
|
5171
5218
|
and not for ever: `applied` to a `complete` says the
|
|
5172
5219
|
provider has completed the task, and its `task-ended` follows within the
|
|
5173
|
-
manifest's `
|
|
5220
|
+
manifest's `settleMs`. A provider never answers `applied` for a completion it has not yet
|
|
5174
5221
|
performed. Past the bound the agent application calls `snapshot()`: a snapshot still carrying the task is a task
|
|
5175
5222
|
held open by a provider that said it was done, and the desk shows it as unsettled -- "Completing...
|
|
5176
5223
|
the provider has not confirmed" -- naming the command; a snapshot no longer carrying it clears the
|
|
@@ -5515,6 +5562,7 @@ cannot be established from TypeScript structure alone.
|
|
|
5515
5562
|
| `assertBreakFollowsItsRequests(envelopes, snapshot?)` | A break follows its requests: a commit's states only after a grant, never backwards, and a forced break arriving in effect with `forced`. The harness applies the same rules after the connect snapshot. |
|
|
5516
5563
|
| `assertAudioFollowsTheTask(envelopes, snapshot?)` | The audio follows the task and never decides it: every task is introduced once, `task-audio-started` and `task-audio-ended` alternate on work that has begun, audio ends only where it arrived, and what follows the audio ending is `completing` or `task-ended`. The harness applies the same rules to every event after the connect snapshot (`stream.*`). A sequence with no audio satisfies it by never testing it — pair it with the assertion that the audio end is present. |
|
|
5517
5564
|
| `assertBreakAttemptProviders(candidates, asked)` | A break attempt asks every usable provider holding capacity, `refreshing` included, and nothing of a provider whose login is `expired`. |
|
|
5565
|
+
| `assertForcedBreakStopsTheRest(forcedOn, candidates, stopped)` | A forced break on one provider stops the agent everywhere else: capacity zero stated on every other usable provider holding capacity, nothing on the forcing one, nothing on a dead login. See **Forced breaks**. |
|
|
5518
5566
|
| `assertBreakBeginsAfterTask(steps)` | A break asked for on a task is committed as `starting-after-task` while work remains and reaches `on-break` only once nothing is outstanding — never beside a task, never later than the step that has none. |
|
|
5519
5567
|
| `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
|
|
5520
5568
|
| `assertWrapTimeout(task, audioEndedAt, deadline, toleranceMs?)` | The wrap deadline equals audio 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. |
|