@xema/omni-protocol 0.1.116 → 0.1.118
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 +73 -30
- package/dist/index.js +10 -1
- package/dist/testing.d.ts +15 -1
- package/dist/testing.js +75 -1
- package/dist/validation.d.ts +19 -9
- package/dist/validation.js +126 -88
- package/guide/agent.md +19 -1
- package/guide/phone.md +1 -1
- package/guide/voice.md +115 -109
- package/guide.md +72 -35
- package/package.json +1 -1
package/dist/validation.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Each list is pinned to its type both ways -- a member the type lacks, or a member the list
|
|
10
10
|
// lacks, fails to compile -- so what the validators accept cannot drift from what the
|
|
11
11
|
// declarations say.
|
|
12
|
-
import { RECORDING_ACTIONS, SHIFT_EVENT_KINDS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HISTORY_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
|
|
12
|
+
import { RECORDING_ACTIONS, SHIFT_EVENT_KINDS, RECORDING_SIDES, RECORDING_STATUSES, RECORDING_PAUSED_REASONS, RECORDING_UNKNOWN_REASONS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HISTORY_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
|
|
13
13
|
export class ProtocolConformanceError extends Error {
|
|
14
14
|
violations;
|
|
15
15
|
constructor(violations, summary = "Adapter violates the Omni protocol") {
|
|
@@ -49,7 +49,7 @@ const TRANSPORT_STATUSES = membersOf({ connecting: true, active: true, error: tr
|
|
|
49
49
|
const AUTHENTICATION_METHODS = membersOf({ "browser-sso": true, credentials: true });
|
|
50
50
|
const AUTHENTICATION_BROWSERS = membersOf({ system: true, omni: true });
|
|
51
51
|
const AUTHENTICATION_STATUSES = membersOf({
|
|
52
|
-
"signed-out": true, authenticating: true, authenticated: true, refreshing: true, expired: true,
|
|
52
|
+
"signed-out": true, authenticating: true, authenticated: true, refreshing: true, expired: true, terminated: true,
|
|
53
53
|
});
|
|
54
54
|
const BREAK_APPROVALS = membersOf({
|
|
55
55
|
"not-requested": true, "awaiting-approval": true, granted: true, "starting-after-task": true, "on-break": true,
|
|
@@ -59,6 +59,7 @@ const TEAM_AVAILABILITIES = membersOf({
|
|
|
59
59
|
});
|
|
60
60
|
const HISTORY_STEPS = membersOf({
|
|
61
61
|
queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
|
|
62
|
+
"callback-requested": true,
|
|
62
63
|
});
|
|
63
64
|
const CUSTOM_UI_CONTROLS = membersOf({ button: true, toggle: true, "menu-item": true });
|
|
64
65
|
const CUSTOM_UI_PLACEMENTS = membersOf({ primary: true, secondary: true, overflow: true });
|
|
@@ -1214,16 +1215,11 @@ function validateTaskInto(task, context, path, into) {
|
|
|
1214
1215
|
if (context.locked !== undefined && isPlainObject(task.party) && (isLocked(task.party.number) || isLocked(task.party.email))) {
|
|
1215
1216
|
validateNothingLeaksInto(task, context.locked, path, into);
|
|
1216
1217
|
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
for (const key of Object.keys(task.recording))
|
|
1223
|
-
into.require(key === "provider", "recording.task.owner", `${path}.recording.${key}`, "host state belongs in HostReport, never provider task updates");
|
|
1224
|
-
if (task.recording.provider !== undefined)
|
|
1225
|
-
into.violations.push(...validateRecordingState(task.recording.provider, `${path}.recording.provider`));
|
|
1226
|
-
}
|
|
1218
|
+
// The provider's own recordings, a list; the host's are on its report, never on a provider's task.
|
|
1219
|
+
into.require(task.recording === undefined, "recording.task.renamed", `${path}.recording`, "a task carries the provider's recordings as recordings, a list with the side each follows; the former recording state is not accepted");
|
|
1220
|
+
if (task.recordings !== undefined) {
|
|
1221
|
+
into.require(context.channel === "voice", "recording.task.channel", `${path}.recordings`, "recording is voice-only");
|
|
1222
|
+
into.violations.push(...validateRecordings(task.recordings, `${path}.recordings`));
|
|
1227
1223
|
}
|
|
1228
1224
|
if (context.member !== true)
|
|
1229
1225
|
validateBrowsers(task.browsers, `${path}.browsers`, into);
|
|
@@ -2786,7 +2782,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
|
|
|
2786
2782
|
if (!offered("recording"))
|
|
2787
2783
|
break;
|
|
2788
2784
|
const policy = isPlainObject(capabilities.recording) ? capabilities.recording.provider : undefined;
|
|
2789
|
-
const state =
|
|
2785
|
+
const state = task.recordings ?? [];
|
|
2790
2786
|
into.violations.push(...validateRecordingCommandState(command, policy, state, path));
|
|
2791
2787
|
if (command.action === "start" || command.action === "resume") {
|
|
2792
2788
|
interaction();
|
|
@@ -3131,18 +3127,28 @@ export function validateAuthenticationState(state, path = "authentication", cont
|
|
|
3131
3127
|
validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
|
|
3132
3128
|
validateUserCapabilitiesInto(state.capabilities, `${path}.capabilities`, into, context.levels);
|
|
3133
3129
|
}
|
|
3134
|
-
else if (state.status === "expired") {
|
|
3130
|
+
else if (state.status === "expired" || state.status === "terminated") {
|
|
3135
3131
|
if (state.identity !== undefined)
|
|
3136
3132
|
validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
|
|
3137
3133
|
if (state.failure !== undefined) {
|
|
3138
3134
|
validateAuthenticationFailureInto(state.failure, `${path}.failure`, into);
|
|
3139
3135
|
}
|
|
3136
|
+
if (state.status === "terminated") {
|
|
3137
|
+
// Ended from outside this agent computer: who did it, and why, and nothing to retry.
|
|
3138
|
+
into.require(isUserId(state.by) || state.by === "provider", "authentication.terminated.by", `${path}.by`, "a terminated session says who ended it: the person by user id, or provider where the platform did");
|
|
3139
|
+
if (into.require(isPlainObject(state.failure), "authentication.terminated.failure", `${path}.failure`, "a terminated session carries the reason it was ended")) {
|
|
3140
|
+
into.require(state.failure.retryable === false, "authentication.terminated.retryable", `${path}.failure.retryable`, "a session ended from outside is over: its failure is not retryable");
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3140
3143
|
}
|
|
3141
3144
|
else {
|
|
3142
3145
|
into.require(state.identity === undefined, "authentication.identity.unexpected", `${path}.identity`, `${state.status} must not carry an identity`);
|
|
3143
3146
|
}
|
|
3144
3147
|
if (state.failure !== undefined) {
|
|
3145
|
-
into.require(state.status === "expired", "authentication.failure.unexpected", `${path}.failure`, "only an expired state may carry a failure");
|
|
3148
|
+
into.require(state.status === "expired" || state.status === "terminated", "authentication.failure.unexpected", `${path}.failure`, "only an expired or terminated state may carry a failure");
|
|
3149
|
+
}
|
|
3150
|
+
if (state.by !== undefined) {
|
|
3151
|
+
into.require(state.status === "terminated", "authentication.by.unexpected", `${path}.by`, "only a terminated session says who ended it; the agent's own sign-out is signed-out");
|
|
3146
3152
|
}
|
|
3147
3153
|
if (state.expiresAt !== undefined) {
|
|
3148
3154
|
into.require(state.status === "authenticated", "authentication.expiresAt.unexpected", `${path}.expiresAt`, "only an authenticated state may carry an expiry");
|
|
@@ -3153,7 +3159,7 @@ export function validateAuthenticationState(state, path = "authentication", cont
|
|
|
3153
3159
|
}
|
|
3154
3160
|
return into.violations;
|
|
3155
3161
|
}
|
|
3156
|
-
//
|
|
3162
|
+
// Recordings: published on change, restated whole, never expiring; and dispatch validation.
|
|
3157
3163
|
const object = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3158
3164
|
const filled = (v) => typeof v === "string" && v.trim().length > 0;
|
|
3159
3165
|
function collector() {
|
|
@@ -3170,40 +3176,49 @@ function collector() {
|
|
|
3170
3176
|
};
|
|
3171
3177
|
return { violations, check, keys };
|
|
3172
3178
|
}
|
|
3173
|
-
/**
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
export function
|
|
3179
|
+
/** A recording that is, or may be, capturing: its side has no room for a second start. */
|
|
3180
|
+
const LIVE_RECORDING = ["starting", "recording", "paused", "unknown"];
|
|
3181
|
+
/**
|
|
3182
|
+
* One recorder's recordings for one task, as published: each with its id, the side it follows,
|
|
3183
|
+
* its status, and a reason where it is paused or unknown; ids unique, and at most one recording
|
|
3184
|
+
* live on each side at once.
|
|
3185
|
+
*/
|
|
3186
|
+
export function validateRecordings(value, path = "recordings") {
|
|
3181
3187
|
const { violations, check, keys } = collector();
|
|
3182
|
-
if (!
|
|
3183
|
-
check(false, "
|
|
3184
|
-
return violations;
|
|
3185
|
-
}
|
|
3186
|
-
if (value.status === "unknown") {
|
|
3187
|
-
keys(value, ["status"], path);
|
|
3188
|
+
if (!Array.isArray(value)) {
|
|
3189
|
+
check(false, "list.shape", path, "recordings are a list, empty when nothing has been recorded");
|
|
3188
3190
|
return violations;
|
|
3189
3191
|
}
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3192
|
+
const ids = new Set();
|
|
3193
|
+
const live = new Set();
|
|
3194
|
+
value.forEach((entry, index) => {
|
|
3195
|
+
const at = `${path}[${index}]`;
|
|
3196
|
+
if (!object(entry)) {
|
|
3197
|
+
check(false, "shape", at, "a recording is an object");
|
|
3198
|
+
return;
|
|
3199
|
+
}
|
|
3200
|
+
keys(entry, ["id", "follows", "status", "reason"], at);
|
|
3201
|
+
check(filled(entry.id), "id", `${at}.id`, "a recording names itself by its recorder's id");
|
|
3202
|
+
check(!ids.has(entry.id), "id.unique", `${at}.id`, "two recordings share an id");
|
|
3203
|
+
ids.add(entry.id);
|
|
3204
|
+
check(RECORDING_SIDES.includes(entry.follows), "follows", `${at}.follows`, "a recording follows the party's side or the agent's");
|
|
3205
|
+
if (!check(RECORDING_STATUSES.includes(entry.status), "status", `${at}.status`, "a recording is starting, recording, paused, not-recording or unknown"))
|
|
3206
|
+
return;
|
|
3207
|
+
const reasons = entry.status === "paused" ? RECORDING_PAUSED_REASONS
|
|
3208
|
+
: entry.status === "unknown" ? RECORDING_UNKNOWN_REASONS : undefined;
|
|
3209
|
+
if (reasons !== undefined) {
|
|
3210
|
+
check(reasons.includes(entry.reason), "reason", `${at}.reason`, `a ${String(entry.status)} recording says why: ${reasons.join(", ")}`);
|
|
3211
|
+
}
|
|
3212
|
+
else {
|
|
3213
|
+
check(entry.reason === undefined, "reason.unexpected", `${at}.reason`, "only a paused or unknown recording carries a reason");
|
|
3214
|
+
}
|
|
3215
|
+
if (LIVE_RECORDING.includes(entry.status)) {
|
|
3216
|
+
check(!live.has(entry.follows), "follows.live", `${at}.follows`, `the ${String(entry.follows)} side already has a recording that is, or may be, capturing`);
|
|
3217
|
+
live.add(entry.follows);
|
|
3218
|
+
}
|
|
3219
|
+
});
|
|
3199
3220
|
return violations;
|
|
3200
3221
|
}
|
|
3201
|
-
/** Supply a trusted current instant in the observer's clock domain. Undefined means clock unknown. */
|
|
3202
|
-
export function effectiveRecordingState(state, now) {
|
|
3203
|
-
if (state === undefined || validateRecordingState(state).length || state.status === "unknown" || now === undefined || !Number.isFinite(now))
|
|
3204
|
-
return { status: "unknown" };
|
|
3205
|
-
return Date.parse(state.observedAt) <= now && now < Date.parse(state.validUntil) ? state : { status: "unknown" };
|
|
3206
|
-
}
|
|
3207
3222
|
export function validateRecordingPolicy(value, path = "capabilities.recording") {
|
|
3208
3223
|
const { violations, check, keys } = collector();
|
|
3209
3224
|
if (!object(value)) {
|
|
@@ -3276,12 +3291,12 @@ export function validateHostRecordings(value, path = "host.recordings") {
|
|
|
3276
3291
|
check(false, "report.shape", at, "expected scoped report");
|
|
3277
3292
|
return;
|
|
3278
3293
|
}
|
|
3279
|
-
keys(report, ["assignmentId", "
|
|
3294
|
+
keys(report, ["assignmentId", "recordings"], at);
|
|
3280
3295
|
check(filled(report.assignmentId), "report.scope", at, "report identifies the assignment");
|
|
3281
3296
|
const key = JSON.stringify([report.assignmentId]);
|
|
3282
|
-
check(!seen.has(key), "report.duplicate", at, "one host
|
|
3297
|
+
check(!seen.has(key), "report.duplicate", at, "one host report per assignment");
|
|
3283
3298
|
seen.add(key);
|
|
3284
|
-
violations.push(...
|
|
3299
|
+
violations.push(...validateRecordings(report.recordings, `${at}.recordings`));
|
|
3285
3300
|
});
|
|
3286
3301
|
return violations;
|
|
3287
3302
|
}
|
|
@@ -3291,50 +3306,62 @@ export function validateRecordingCommandShape(value, source, path = "command") {
|
|
|
3291
3306
|
check(false, "command.shape", path, "expected recording command");
|
|
3292
3307
|
return violations;
|
|
3293
3308
|
}
|
|
3294
|
-
|
|
3309
|
+
const starting = value.action === "start";
|
|
3310
|
+
keys(value, ["type", "source", "action", starting ? "follows" : "recordingId"], path);
|
|
3295
3311
|
check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
|
|
3296
3312
|
check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
|
|
3297
|
-
|
|
3298
|
-
check(
|
|
3313
|
+
if (starting)
|
|
3314
|
+
check(RECORDING_SIDES.includes(value.follows), "command.follows", `${path}.follows`, "a start names the side to record: party or agent");
|
|
3315
|
+
else
|
|
3316
|
+
check(filled(value.recordingId), "command.identity", `${path}.recordingId`, "every action but start names the recording by its id");
|
|
3299
3317
|
return violations;
|
|
3300
3318
|
}
|
|
3301
3319
|
/** One transition table shared by dispatch checks and outcome checks. */
|
|
3302
3320
|
const RECORDING_TRANSITIONS = {
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
cancel: { from: ["active", "paused"], to: "inactive" },
|
|
3321
|
+
pause: { from: ["recording"], to: "paused" },
|
|
3322
|
+
resume: { from: ["paused"], to: "recording" },
|
|
3323
|
+
stop: { from: ["recording", "paused"], to: "not-recording" },
|
|
3324
|
+
cancel: { from: ["recording", "paused"], to: "not-recording" },
|
|
3308
3325
|
};
|
|
3309
3326
|
function recordingTransition(action) {
|
|
3310
3327
|
return typeof action === "string" && Object.hasOwn(RECORDING_TRANSITIONS, action)
|
|
3311
3328
|
? RECORDING_TRANSITIONS[action] : undefined;
|
|
3312
3329
|
}
|
|
3313
|
-
|
|
3314
|
-
|
|
3330
|
+
const entries = (value) => Array.isArray(value) ? value.filter(object) : [];
|
|
3331
|
+
/**
|
|
3332
|
+
* Whether the action is permitted on this recorder, and whether the recordings as last published
|
|
3333
|
+
* let it act: a start on a side with nothing capturing, every other action on the recording it
|
|
3334
|
+
* names, standing where the action needs it. Dispatch MUST also call validateRecordingRequest.
|
|
3335
|
+
*/
|
|
3336
|
+
export function validateRecordingCommandState(command, policy, recordings, path = "command") {
|
|
3315
3337
|
const { violations, check } = collector();
|
|
3316
3338
|
if (!object(command)) {
|
|
3317
3339
|
check(false, "command.shape", path, "expected recording command");
|
|
3318
3340
|
return violations;
|
|
3319
3341
|
}
|
|
3320
|
-
|
|
3321
|
-
if (transition === undefined) {
|
|
3342
|
+
if (!RECORDING_ACTIONS.includes(command.action)) {
|
|
3322
3343
|
check(false, "command.action", `${path}.action`, "unsupported recording action");
|
|
3323
3344
|
return violations;
|
|
3324
3345
|
}
|
|
3325
3346
|
const permitted = object(policy) && policy[command.action] === true;
|
|
3326
3347
|
check(permitted, "command.permission", path, "task does not permit this action on this recorder");
|
|
3327
|
-
if (
|
|
3328
|
-
check(false, "command.state", path, "
|
|
3348
|
+
if (recordings !== undefined && validateRecordings(recordings).length) {
|
|
3349
|
+
check(false, "command.state", path, "the recordings as published are malformed");
|
|
3329
3350
|
return violations;
|
|
3330
3351
|
}
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3352
|
+
const list = entries(recordings);
|
|
3353
|
+
if (command.action === "start") {
|
|
3354
|
+
check(!list.some(entry => entry.follows === command.follows && LIVE_RECORDING.includes(entry.status)), "command.state", path, `the ${String(command.follows)} side already has a recording that is, or may be, capturing`);
|
|
3355
|
+
return violations;
|
|
3356
|
+
}
|
|
3357
|
+
const target = list.find(entry => entry.id === command.recordingId);
|
|
3358
|
+
if (!check(target !== undefined, "command.recordingId", path, "no recording on this task has that id"))
|
|
3359
|
+
return violations;
|
|
3360
|
+
const transition = recordingTransition(command.action);
|
|
3361
|
+
check(transition.from.includes(target.status), "command.state", path, `${String(command.action)} needs the recording ${transition.from.join(" or ")}, and it is ${String(target.status)}`);
|
|
3335
3362
|
return violations;
|
|
3336
3363
|
}
|
|
3337
|
-
/** Validate immediately before dispatch
|
|
3364
|
+
/** Validate immediately before dispatch, against the current task and, for the host's recorder, its declaration and report. */
|
|
3338
3365
|
export function validateRecordingRequest(request, task, context, path = "request") {
|
|
3339
3366
|
const { violations, check, keys } = collector();
|
|
3340
3367
|
if (!object(request) || !object(task)) {
|
|
@@ -3352,58 +3379,69 @@ export function validateRecordingRequest(request, task, context, path = "request
|
|
|
3352
3379
|
const caps = object(task.capabilities) ? task.capabilities.recording : undefined;
|
|
3353
3380
|
violations.push(...validateRecordingPolicy(caps)); // a locked or missing capability cannot dispatch
|
|
3354
3381
|
const policy = object(caps) ? caps[context.source] : undefined;
|
|
3355
|
-
let
|
|
3382
|
+
let recordings = task.recordings ?? [];
|
|
3356
3383
|
if (context.source === "host") {
|
|
3357
3384
|
violations.push(...validateHostRecording(context.host, context.softphone === true));
|
|
3358
3385
|
check(object(context.host), "host.required", path, "host did not declare recording support");
|
|
3359
3386
|
const reports = object(context.hostReport) ? context.hostReport.recordings : undefined;
|
|
3360
3387
|
violations.push(...validateHostRecordings(reports));
|
|
3361
|
-
|
|
3388
|
+
const report = Array.isArray(reports) ? reports.find(r => object(r) && r.assignmentId === task.assignmentId) : undefined;
|
|
3389
|
+
recordings = object(report) ? report.recordings : [];
|
|
3362
3390
|
if (object(context.host)) {
|
|
3363
3391
|
check(Array.isArray(context.host.actions) && context.host.actions.includes(command.action), "host.action", path, "host does not support action");
|
|
3364
3392
|
check(object(policy) && Array.isArray(context.host.storageIds) && context.host.storageIds.includes(policy.storageId), "host.storage", path, "task storage is not one this host provisions");
|
|
3365
3393
|
}
|
|
3366
3394
|
}
|
|
3367
|
-
|
|
3368
|
-
check(fresh.status !== "unknown", "request.freshness", path, "current state or trusted observer time unavailable/expired");
|
|
3369
|
-
violations.push(...validateRecordingCommandState(command, policy, fresh, `${path}.command`));
|
|
3395
|
+
violations.push(...validateRecordingCommandState(command, policy, recordings, `${path}.command`));
|
|
3370
3396
|
const opening = command.action === "start" || command.action === "resume";
|
|
3371
3397
|
check(opening ? ["in-progress", "paused"].includes(task.phase) && task.audio === "started" : ["in-progress", "paused", "completing"].includes(task.phase), "request.phase", path, "capture needs live task audio; terminal controls may finish recordings during wrap-up");
|
|
3372
3398
|
return violations;
|
|
3373
3399
|
}
|
|
3374
|
-
/**
|
|
3400
|
+
/**
|
|
3401
|
+
* A recording command's answer against the recordings published before and after it: applied
|
|
3402
|
+
* means the recorder confirmed what was asked -- a new recording on the side for a start, the
|
|
3403
|
+
* same recording in its new status for anything else -- and failed means nothing changed there.
|
|
3404
|
+
*/
|
|
3375
3405
|
export function validateRecordingOutcome(command, before, after, result, path = "recordingOutcome") {
|
|
3376
3406
|
const { violations, check } = collector();
|
|
3377
|
-
violations.push(...
|
|
3378
|
-
if (!object(command) || !
|
|
3379
|
-
check(false, "outcome.shape", path, "command and both
|
|
3407
|
+
violations.push(...validateRecordings(before, `${path}.before`), ...validateRecordings(after, `${path}.after`));
|
|
3408
|
+
if (!object(command) || !Array.isArray(before) || !Array.isArray(after)) {
|
|
3409
|
+
check(false, "outcome.shape", path, "command and both lists of recordings required");
|
|
3380
3410
|
return violations;
|
|
3381
3411
|
}
|
|
3382
3412
|
violations.push(...validateRecordingCommandShape(command, command.source === "host" ? "host" : "provider", `${path}.command`));
|
|
3383
|
-
// A rejected promise has no result: any
|
|
3413
|
+
// A rejected promise has no result: any state its recorder publishes may follow it.
|
|
3384
3414
|
if (result === undefined)
|
|
3385
3415
|
return violations;
|
|
3386
3416
|
violations.push(...validateResult(result, "execute", `${path}.result`));
|
|
3387
3417
|
if (!object(result))
|
|
3388
3418
|
return violations;
|
|
3389
3419
|
check(result.status === "applied" || result.status === "failed", "outcome.result", path, "recording never returns a dialling result");
|
|
3420
|
+
const prior = entries(before);
|
|
3421
|
+
const next = entries(after);
|
|
3422
|
+
if (command.action === "start") {
|
|
3423
|
+
const fresh = next.filter(entry => entry.follows === command.follows && !prior.some(old => old.id === entry.id));
|
|
3424
|
+
if (result.status === "applied") {
|
|
3425
|
+
check(fresh.some(entry => entry.status === "recording"), "outcome.state", path, "an applied start shows a new recording on that side, recording");
|
|
3426
|
+
check(!prior.some(entry => entry.follows === command.follows && LIVE_RECORDING.includes(entry.status)), "outcome.origin", path, "applied cannot claim a start on a side already capturing");
|
|
3427
|
+
}
|
|
3428
|
+
else if (result.status === "failed") {
|
|
3429
|
+
check(!fresh.some(entry => LIVE_RECORDING.includes(entry.status)), "outcome.noEffect", path, "a failed start leaves nothing capturing on that side");
|
|
3430
|
+
}
|
|
3431
|
+
return violations;
|
|
3432
|
+
}
|
|
3433
|
+
const was = prior.find(entry => entry.id === command.recordingId);
|
|
3434
|
+
const now = next.find(entry => entry.id === command.recordingId);
|
|
3390
3435
|
if (result.status === "failed") {
|
|
3391
|
-
check(
|
|
3436
|
+
check(was !== undefined && now !== undefined && now.status === was.status, "outcome.noEffect", path, "failed confirms no effect: the recording stands as it was; a partial effect is unknown, unsettled");
|
|
3392
3437
|
}
|
|
3393
3438
|
else if (result.status === "applied") {
|
|
3394
3439
|
const transition = recordingTransition(command.action);
|
|
3395
3440
|
if (transition === undefined)
|
|
3396
3441
|
return violations; // shape validation already reported the unknown action
|
|
3397
|
-
check(
|
|
3398
|
-
check(
|
|
3399
|
-
check(
|
|
3400
|
-
check(transition.from.includes(before.status), "outcome.origin", path, "applied cannot claim an impossible transition");
|
|
3401
|
-
if (command.action !== "start")
|
|
3402
|
-
check(command.recordingId === before.recordingId, "outcome.recordingId", path, "command must target the prior recording");
|
|
3403
|
-
if (command.action === "pause" || command.action === "resume")
|
|
3404
|
-
check(after.recordingId === before.recordingId, "outcome.continuity", path, "pause/resume preserves recording identity");
|
|
3405
|
-
if (instant(before.observedAt) && instant(after.observedAt))
|
|
3406
|
-
check(after.observedAt >= before.observedAt, "outcome.order", path, "clock discontinuity requires reconciliation, not a reversed confirmation");
|
|
3442
|
+
check(now !== undefined, "outcome.continuity", path, "the recording keeps its id through the action");
|
|
3443
|
+
check(now?.status === transition.to, "outcome.state", path, `applied requires the recording ${transition.to}`);
|
|
3444
|
+
check(was !== undefined && transition.from.includes(was.status), "outcome.origin", path, "applied cannot claim an impossible transition");
|
|
3407
3445
|
}
|
|
3408
3446
|
return violations;
|
|
3409
3447
|
}
|
package/guide/agent.md
CHANGED
|
@@ -384,10 +384,14 @@ Migration from the earlier spellings:
|
|
|
384
384
|
| the phone unspoken | `PhoneState` -- the device, its mute, its channels -- on `Snapshot.phone`, `phone-updated` and `TeamMember.phone`, from a manifest declaring `phoneStatus` (`snapshot.phone.required`, `.unexpected`, `phone.*`, `phone.channel.*`, `event.phone.capability`) |
|
|
385
385
|
| the agent's readiness for the next call read into setCapacity | the agent's own queue: `capabilities.nextCall`, `requestNextCall`/`cancelNextCall`/`releaseLinedUp`, `Snapshot.nextCall` and `linedUp`, the `next-call` and `lined-up` events (`snapshot.nextCall.idle`, `nextCall.*`, `linedUp.*`) |
|
|
386
386
|
| a harness that exercised an adapter, drove a call and evaluated rules | the harness reads as a test: `testAdapter`, `withCall`, `timeoutMs`, `notTested`, `rulesTested`, rules `test.*` (`test.timeout`, `test.offer.expired`, `test.preview.deadline`, ...) |
|
|
387
|
+
| a `callback-requested` step, and a callback task starting its history afresh | a `callback-requested` step (`by` when an agent arranged it); a callback's task carries the earlier call's steps, oldest first |
|
|
388
|
+
| a recording state with an observation id, an observation instant and a validity window, and the helpers that aged it | `recordings` on the task and on `HostRecordingReport`: a list, each recording with `id`, `follows` (`party` or `agent`), `status` (`starting`, `recording`, `paused`, `not-recording`, `unknown`) and a `reason` where paused or unknown; nothing expires; commands name `follows` for a start and `recordingId` otherwise; `validateRecordings` (`recording.task.renamed` refuses the former field) |
|
|
389
|
+
| a refusal on hold under `omni.command-not-permitted` | `omni.on-hold` |
|
|
387
390
|
| an `end-call` while the caller was on hold | refused: a `paused` task is resumed first (`command.endCall.held`), the agent computer shows End call disabled on hold, the provider answers `failed`, and the test names an adapter that ends from hold (`test.command.held`); `terminate-call` is not gated, it ends the caller's channel too |
|
|
388
391
|
| the handset spelled by its place on a desk | `hardphone`, on the manifest's `phones` and the login's `phone`; the former spelling is refused |
|
|
389
392
|
| the desk | the agent computer, in the guide's prose |
|
|
390
393
|
| the agent application's Mute on hold | unavailable while the task is `paused`, promised at login by a softphone login's agent application (`host.guarantees.muteUnavailableOnHold`, required there and refused of a hardphone login); a host mute beginning on a paused task is refused (`historyReport.muted.held`) and the test names an adapter that records one (`test.recordStep.held`) |
|
|
394
|
+
| a session ended from outside the agent computer, reported as `expired` | `terminated`, with `by` (a person or `provider`) and a reason never retryable; `signed-out` is only the agent's own Sign out here; an ended session's secrets are deleted before the ending is published (`authentication.secrets.retained`), kept only for an `expired` whose failure is retryable; the agent application clears the store after, and resumes no stored token |
|
|
391
395
|
|
|
392
396
|
Update producers, consumers, saved task snapshots, and validation-rule assertions together.
|
|
393
397
|
History and report rule names use `history` and `historyReport`; assignment rules use
|
|
@@ -425,7 +429,20 @@ something else, which will arrive under its own name and must not be folded in h
|
|
|
425
429
|
It rides in the snapshot and is replaced whole like everything else there.
|
|
426
430
|
|
|
427
431
|
Steps are `queued`, `offered`, `answered`, `held`, `muted`, `transferred`, `conferenced`,
|
|
428
|
-
`unanswered`, and each is defined on `TaskHistoryStep`.
|
|
432
|
+
`unanswered`, `callback-requested`, and each is defined on `TaskHistoryStep`.
|
|
433
|
+
|
|
434
|
+
**A callback carries the call it comes back to.** When a caller asks to be called back, or an agent
|
|
435
|
+
arranges it, the provider writes a `callback-requested` step: `by` names the agent who arranged it, and
|
|
436
|
+
is absent when the caller chose it in the queue. The callback reaches an agent as a new task, and that
|
|
437
|
+
task's history carries the earlier call's steps, oldest first, the `callback-requested` step among them,
|
|
438
|
+
then the callback's own -- as a hand-over carries the steps before it. The agent opening a callback
|
|
439
|
+
sees that the caller waited and asked, or whose promise they are keeping, and a second try reads as
|
|
440
|
+
one, the way contact-centre platforms keep a callback with its original contact.
|
|
441
|
+
|
|
442
|
+
**One call has one history.** Every task the call reaches restates the earlier steps exactly as they
|
|
443
|
+
stood: the same step, instant, person, dial and destination, and a duration once stated never
|
|
444
|
+
changes. `assertHistoryAgreesAcrossTasks(earlier, later)` holds two tasks of one call to that, for an
|
|
445
|
+
adopter's own test that drives a hand-over, take-over or callback.
|
|
429
446
|
|
|
430
447
|
**The record is one entry per occurrence, oldest first.** Each hold is its own `held` entry — `at`
|
|
431
448
|
when it began, `seconds` once it ended and omitted while it runs — and a second hold is a second
|
|
@@ -1098,6 +1115,7 @@ react rather than only display the message:
|
|
|
1098
1115
|
| `omni.unavailable` | The provider is temporarily unable to serve the action, including any command sent while `transport-status` is not `active`. |
|
|
1099
1116
|
| `omni.break-already-committed` | Cancellation lost the commit/cancel race; Omni must finish commit recovery. |
|
|
1100
1117
|
| `omni.recording-unsettled` | A recording command the provider could not settle either way: a settled `failed`, never a promise left unresolved. See **Independent task recording** in `guide/voice.md`. |
|
|
1118
|
+
| `omni.on-hold` | Refused because the caller is on hold: the agent application's own Mute begun on a `paused` task, or `end-call` there. The agent application says resume first. The test expects this code on both (`test.recordStep.held`, `test.command.held`). |
|
|
1101
1119
|
| `omni.break-forced-by-provider` | A lead asked to lift a break the platform imposed; the platform lifts its own. See **Forced breaks** in `guide/breaks.md`. |
|
|
1102
1120
|
|
|
1103
1121
|
They are published as `OMNI_FAILURE_CODES`.
|
package/guide/phone.md
CHANGED
|
@@ -238,7 +238,7 @@ softphone the agent application's own Mute is unavailable while the task is `pau
|
|
|
238
238
|
promised at login (`muteUnavailableOnHold`): mute then hold is ordinary, and the muted leg runs on
|
|
239
239
|
across the held one; hold then mute is not, and a `muted` leg with `mutedBy: "host"` beginning on a
|
|
240
240
|
paused task is held back by the agent application (`historyReport.muted.held`) and refused by a
|
|
241
|
-
provider that receives one anyway. The test sends one past the validator while the task is held
|
|
241
|
+
provider that receives one anyway, `failed` with `omni.on-hold`. The test sends one past the validator while the task is held
|
|
242
242
|
and names an adapter that records it (`test.recordStep.held`). A station mute the agent
|
|
243
243
|
application observes on hold -- the headset's slider, the operating system -- is a fact, reported
|
|
244
244
|
with `mutedBy: "station"`, and stands. The agent application reports every such period
|