@xema/omni-protocol 0.1.62 → 0.1.64
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/testing.d.ts +8 -0
- package/dist/testing.js +109 -13
- package/guide.md +18 -2
- package/package.json +1 -1
package/dist/testing.d.ts
CHANGED
|
@@ -43,6 +43,14 @@ export interface ExerciseAdapterOptions {
|
|
|
43
43
|
drive?: boolean;
|
|
44
44
|
/** How long the drive waits for each thing the provider owes it. Defaults to 5000. */
|
|
45
45
|
driveTimeoutMs?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Builds the adapter again, as a host reload does: a new object with no memory, for the same
|
|
48
|
+
* login. Given it, the drive -- once the host's muted leg is recorded -- connects a second adapter
|
|
49
|
+
* with the same context and the same store and expects its snapshot to carry the task with that
|
|
50
|
+
* leg. A platform that holds the record hands it back; an adapter that composed the record in
|
|
51
|
+
* memory has nothing, and is named. Without it the store is required and never read.
|
|
52
|
+
*/
|
|
53
|
+
rebuild?: () => Adapter<Channel>;
|
|
46
54
|
}
|
|
47
55
|
/**
|
|
48
56
|
* Adapter conformance exercise: validates the manifest, opens an authenticated session,
|
package/dist/testing.js
CHANGED
|
@@ -161,14 +161,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
161
161
|
const breaks = new BreakStream();
|
|
162
162
|
let seeded = false;
|
|
163
163
|
const storedSecrets = new Map();
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
},
|
|
171
|
-
});
|
|
164
|
+
const authenticationSecrets = {
|
|
165
|
+
get: async (key) => storedSecrets.get(key),
|
|
166
|
+
set: async (key, value) => { storedSecrets.set(key, value); },
|
|
167
|
+
delete: async (key) => { storedSecrets.delete(key); },
|
|
168
|
+
};
|
|
169
|
+
const authentication = await adapter.createAuthenticationSession({ ...context, secrets: authenticationSecrets });
|
|
172
170
|
let connection;
|
|
173
171
|
let unsubscribe;
|
|
174
172
|
let unsubscribeAuthentication;
|
|
@@ -274,7 +272,10 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
274
272
|
// What the host's Mute does is stated where the host holds a microphone, and nowhere else.
|
|
275
273
|
violations.push(...validateHostMute(context.host.mute, softphone, "context.host.mute"));
|
|
276
274
|
// The login's store is the host's to provide: an adapter that composes a record keeps in it what its platform cannot.
|
|
277
|
-
|
|
275
|
+
const storeShape = validateLoginStore(context.store, "context.store");
|
|
276
|
+
violations.push(...storeShape);
|
|
277
|
+
// The harness watches the store it hands over, so what an adapter leaves behind is seen rather than asked for.
|
|
278
|
+
const watched = storeShape.length === 0 ? watchStore(context.store) : { store: context.store, held: new Set() };
|
|
278
279
|
unsubscribeHost = context.host.subscribe(report => {
|
|
279
280
|
violations.push(...validateHostReport(report, "context.host"));
|
|
280
281
|
});
|
|
@@ -286,7 +287,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
286
287
|
report: () => { consulted = true; return context.host.report(); },
|
|
287
288
|
subscribe: listener => { consulted = true; return context.host.subscribe(listener); },
|
|
288
289
|
};
|
|
289
|
-
|
|
290
|
+
const connected = { ...context, host, store: watched.store };
|
|
291
|
+
connection = await adapter.connect(connected);
|
|
290
292
|
const live = connection;
|
|
291
293
|
// Dial is declared by presence: the capability object carries a destination policy rather
|
|
292
294
|
// than an `enabled` flag, so its presence is the declaration.
|
|
@@ -376,6 +378,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
376
378
|
violations.push(...await driveOneCall({
|
|
377
379
|
connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
|
|
378
380
|
timeoutMs: options.driveTimeoutMs ?? 5000,
|
|
381
|
+
context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held,
|
|
379
382
|
}));
|
|
380
383
|
}
|
|
381
384
|
}
|
|
@@ -956,6 +959,81 @@ async function driveOneCall(drive) {
|
|
|
956
959
|
};
|
|
957
960
|
drive.waiters.add(waiter);
|
|
958
961
|
});
|
|
962
|
+
const recordSurvivesReload = async (at) => {
|
|
963
|
+
let again;
|
|
964
|
+
try {
|
|
965
|
+
again = drive.rebuild();
|
|
966
|
+
}
|
|
967
|
+
catch (error) {
|
|
968
|
+
refuse("drive.reload.rejected", "drive.reload", `building the adapter again threw: ${String(error)}`);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
let session;
|
|
972
|
+
let second;
|
|
973
|
+
try {
|
|
974
|
+
// The same provider, built again: a different manifest is a different adapter, and proves nothing about this one.
|
|
975
|
+
if (again.manifest.id !== drive.manifest.id) {
|
|
976
|
+
refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
|
|
980
|
+
second = await again.connect(drive.context);
|
|
981
|
+
const snapshot = await second.snapshot();
|
|
982
|
+
// The second adapter's snapshot is held to everything a first one is.
|
|
983
|
+
found.push(...validateSnapshot(snapshot, again.manifest, "drive.reload.snapshot", drive.reader()));
|
|
984
|
+
const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
|
|
985
|
+
? snapshot.tasks.find(task => isRecord(task) && task.id === taskId) : undefined;
|
|
986
|
+
if (carried === undefined) {
|
|
987
|
+
refuse("drive.reload.snapshot", "drive.reload.snapshot", `a second adapter built from the same login does not carry ${taskId} on its snapshot, and the task is still open`);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const history = carried.handlingHistory;
|
|
991
|
+
const steps = isRecord(history) && Array.isArray(history.steps) ? history.steps : [];
|
|
992
|
+
// A record once read is not unread across a reload either: every entry the first adapter published is here.
|
|
993
|
+
const before = latestTask().handlingHistory;
|
|
994
|
+
const wasRead = isRecord(before) && Array.isArray(before.steps) ? before.steps.filter(isRecord) : [];
|
|
995
|
+
const lost = wasRead.filter(entry => !steps.some(now => isRecord(now) && now.step === entry.step && now.at === entry.at))
|
|
996
|
+
.map(entry => `${String(entry.step)}@${String(entry.at)}`);
|
|
997
|
+
if (lost.length > 0) {
|
|
998
|
+
refuse("drive.reload.history", "drive.reload.history", `a second adapter built from the same login carries ${taskId} without ${lost.join(", ")}, which the first had published: a record once read is not unread`);
|
|
999
|
+
}
|
|
1000
|
+
const leg = steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
|
|
1001
|
+
if (leg === undefined) {
|
|
1002
|
+
refuse("drive.reload.history", "drive.reload.history", `a second adapter built from the same login carries ${taskId} without the muted leg at ${at}: the record was composed in memory and died with the adapter; the login's store is where it lives`);
|
|
1003
|
+
}
|
|
1004
|
+
else if (leg.mutedBy !== "host") {
|
|
1005
|
+
refuse("drive.reload.history", "drive.reload.history", `the reloaded record's muted leg at ${at} says mutedBy ${String(leg.mutedBy)}; the host reported host`);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
catch (error) {
|
|
1009
|
+
refuse("drive.reload.rejected", "drive.reload", `the second adapter rejected rather than answered: ${String(error)}`);
|
|
1010
|
+
}
|
|
1011
|
+
finally {
|
|
1012
|
+
try {
|
|
1013
|
+
await second?.disconnect();
|
|
1014
|
+
}
|
|
1015
|
+
catch (error) {
|
|
1016
|
+
refuse("drive.reload.rejected", "drive.reload", `the second adapter's disconnect threw: ${String(error)}`);
|
|
1017
|
+
}
|
|
1018
|
+
try {
|
|
1019
|
+
await session?.close();
|
|
1020
|
+
}
|
|
1021
|
+
catch (error) {
|
|
1022
|
+
refuse("drive.reload.rejected", "drive.reload", `the second adapter's session threw on close: ${String(error)}`);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
// A task's keys are gone with the task. A task-scoped key carries the task id, so the watched
|
|
1027
|
+
// store says which keys were the task's; one still held after task-ended is a key about to be
|
|
1028
|
+
// inherited by the next offer of the same id, since a platform retires an id minutes after closing it.
|
|
1029
|
+
const storeReleased = async () => {
|
|
1030
|
+
await Promise.resolve();
|
|
1031
|
+
await Promise.resolve();
|
|
1032
|
+
const retained = [...drive.held].filter(key => key.includes(taskId));
|
|
1033
|
+
if (retained.length > 0) {
|
|
1034
|
+
refuse("drive.store.retained", "drive.store", `${taskId} has ended and the login's store still holds ${retained.join(", ")}: a task's keys go with the task, or the next offer of the same id inherits them`);
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
959
1037
|
// The task, as first seen: on the snapshot, or on the first offer.
|
|
960
1038
|
let cursor = 0;
|
|
961
1039
|
let task = isRecord(drive.snapshot) && Array.isArray(drive.snapshot.tasks) ? drive.snapshot.tasks.find(isTask) : undefined;
|
|
@@ -1113,6 +1191,11 @@ async function driveOneCall(drive) {
|
|
|
1113
1191
|
setMuted(false);
|
|
1114
1192
|
await report({ seconds: Math.round((Date.now() - began) / 1000), ended: true });
|
|
1115
1193
|
mutedLeg = { at, task: latestTask() };
|
|
1194
|
+
// 3c. A host reload destroys the adapter object and keeps the login's store. Built again from
|
|
1195
|
+
// the same login, a second adapter carries the task and the leg -- from its platform, or from
|
|
1196
|
+
// the store -- or it composed the record in memory and the record died with it.
|
|
1197
|
+
if (drive.rebuild !== undefined)
|
|
1198
|
+
await recordSurvivesReload(at);
|
|
1116
1199
|
}
|
|
1117
1200
|
// 4. Hold and resume, where offered.
|
|
1118
1201
|
if (latestTask().phase === "in-progress" && offers("hold")) {
|
|
@@ -1156,11 +1239,12 @@ async function driveOneCall(drive) {
|
|
|
1156
1239
|
if (isRecord(dispositions) && dispositions.required === true && Array.isArray(dispositions.codes) && isRecord(dispositions.codes[0])) {
|
|
1157
1240
|
command.disposition = dispositions.codes[0].id;
|
|
1158
1241
|
}
|
|
1159
|
-
if (await send(command) !== undefined)
|
|
1160
|
-
await
|
|
1242
|
+
if (await send(command) !== undefined && await ended() !== undefined)
|
|
1243
|
+
await storeReleased();
|
|
1161
1244
|
}
|
|
1162
1245
|
else if (latestTask().phase === "completing") {
|
|
1163
|
-
await ended()
|
|
1246
|
+
if (await ended() !== undefined)
|
|
1247
|
+
await storeReleased();
|
|
1164
1248
|
}
|
|
1165
1249
|
// The record kept by the provider has the leg the host reported, wherever the provider restated it.
|
|
1166
1250
|
if (mutedLeg !== undefined && latestTask() !== mutedLeg.task) {
|
|
@@ -1255,6 +1339,18 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
|
|
|
1255
1339
|
envelopes.forEach((envelope, index) => found.push(...stream.apply(envelope, `envelopes[${index}]`)));
|
|
1256
1340
|
assertNoViolations(found, "The media follows the task");
|
|
1257
1341
|
}
|
|
1342
|
+
/** The store as handed to the adapter, and the keys it holds at any moment, seen rather than reported. */
|
|
1343
|
+
function watchStore(store) {
|
|
1344
|
+
const held = new Set();
|
|
1345
|
+
return {
|
|
1346
|
+
held,
|
|
1347
|
+
store: {
|
|
1348
|
+
get: key => store.get(key),
|
|
1349
|
+
set: async (key, value) => { await store.set(key, value); held.add(key); },
|
|
1350
|
+
delete: async (key) => { await store.delete(key); held.delete(key); },
|
|
1351
|
+
},
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1258
1354
|
/** A login store that lives in memory for the test: what most adapter tests hand `exerciseAdapter` as `context.store`. */
|
|
1259
1355
|
export function memoryStore() {
|
|
1260
1356
|
const kept = new Map();
|
package/guide.md
CHANGED
|
@@ -1946,7 +1946,7 @@ Creates one live provider connection for the signed-in agent.
|
|
|
1946
1946
|
| `autoAcceptTasks` | Agent provisioning policy relayed to the provider at login. Treated as `true` when omitted. When `true`, a pending task states its `acceptance`; when `false`, every task requires agent acceptance. Fixed for this connection, like everything else here: the provider states or omits `acceptance` by the value it was sent, and Omni validates by that same value, not by a policy that has since moved — a change reaches the provider through a fresh `connect()`. |
|
|
1947
1947
|
| `timeZone` | The same value passed as `AuthenticationContext.timeZone`. The provider stores it on the agent and carries it on the identity. See **The agent's day**. |
|
|
1948
1948
|
| `phone` | The same value passed as `AuthenticationContext.phone`: how this login hears its calls. The type ties `host.mute` to it: a `softphone` login's host states what its Mute does, and a desk-phone or conversation login's host cannot, so the omission is a compile error rather than a live seat's discovery. See **The station is the host's**. |
|
|
1949
|
-
| `store` | The login's operational store, kept by the host for the life of the login, across a reload of the host, and cleared at sign-out: where an adapter that composes a record keeps what its platform cannot hold for it, such as the handling legs a host reported. Three functions, by key. Never for anything sensitive, which is `AuthenticationContext.secrets`, a store a host may clear aggressively. The harness requires
|
|
1949
|
+
| `store` | The login's operational store, kept by the host for the life of the login, across a reload of the host, and cleared at sign-out: where an adapter that composes a record keeps what its platform cannot hold for it, such as the handling legs a host reported. Three functions, by key. Never for anything sensitive, which is `AuthenticationContext.secrets`, a store a host may clear aggressively. **A task's keys carry the task id and go with the task**: a key written about a task names the task's id in the key, and is deleted before the task's end is published, because a platform retires a task id minutes after closing it and a requeue takes seconds, so a key that outlives its task is inherited by the next offer of the same id -- a record with legs the host never reported against it. A login-scoped key carries no task id and outlives any task. The harness requires the store of every connection (`store.shape`, `store.get`, `.set`, `.delete`), watches the one it hands over, and names a task's key still held after `task-ended` (`drive.store.retained`); an adapter that names no task in its keys gets no cleanup check, which is a gap rather than a pass, never an exemption: the obligation is that nothing of a closed task survives its ending, and an adapter that keeps every open task in one login-scoped value owes exactly that inside the value, where the harness cannot look. One key per task, named for it, is the shape the harness can hold, and the shape to reach for. The store lists nothing, so an adapter that needs to find its tasks keeps a login-scoped index of ids beside them; at the task's end it deletes the body first and reindexes after, since a crash between the two then leaves an index naming a task with no body, which a reader skips, where the other order leaves a body for a task that has ended, which is the hazard itself. A reader of the index tolerates an id with no body as an ending that was underway, not as corruption. |
|
|
1950
1950
|
| `host` | The host's report of the agent's station — devices, permissions, network — to consult before declaring the agent ready to the platform, and on every change. See **The host reports, the adapter decides**. |
|
|
1951
1951
|
| `signal` | Optional cancellation signal. Stop startup promptly when aborted and do not begin new work. |
|
|
1952
1952
|
| `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
|
|
@@ -4590,7 +4590,23 @@ violation (`drive.command.failed`), and an event the provider owes and never sen
|
|
|
4590
4590
|
the drive mutes it for one second and reports the leg through `recordStep`, begun and then ended,
|
|
4591
4591
|
expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); and where the provider
|
|
4592
4592
|
restates the task's record afterwards, the leg is in it or the hole is named
|
|
4593
|
-
(`drive.recordStep.history`).
|
|
4593
|
+
(`drive.recordStep.history`). Given `rebuild`, a way to build the adapter again as a host reload
|
|
4594
|
+
does, the drive then connects a second adapter for the same login with the same context and the
|
|
4595
|
+
same `store`, and expects its snapshot to carry the task with that leg and the host's word: a
|
|
4596
|
+
platform that holds the record hands it back, and an adapter that composed the record in memory
|
|
4597
|
+
has nothing and is named (`drive.reload.snapshot`, `drive.reload.history`, `.rejected`). The second
|
|
4598
|
+
adapter is held to what the first was: the same provider (`drive.reload.manifest`), a snapshot that
|
|
4599
|
+
stands as any snapshot must, and a record that lost none of the entries the first had published --
|
|
4600
|
+
a record once read is not unread across a reload either. A reconnect on the same adapter object
|
|
4601
|
+
would prove nothing, since an in-process adapter's memory survives it; only a second object
|
|
4602
|
+
separates kept-in-the-store from never-lost. The second adapter opens its session from the same
|
|
4603
|
+
`secrets`, so the reload is a restore before it is anything else: an adapter that holds a session
|
|
4604
|
+
has already put what would rebuild it into the secrets store, from the moment it was handed one,
|
|
4605
|
+
not only when a flow completes -- the session is the adapter's, the store is the host's, and a
|
|
4606
|
+
host reload is exactly when no flow will run. And once the task has ended, the store the drive
|
|
4607
|
+
handed the adapter holds no key carrying the task's id (`drive.store.retained`): a task's keys go
|
|
4608
|
+
with the task, or the next offer of the same id inherits them. Outside the
|
|
4609
|
+
handling phases with `hold` still declared -- in
|
|
4594
4610
|
`confirmed`, where the provider publishes it, and in `completing` once the call has ended -- the
|
|
4595
4611
|
drive sends `hold` past the validator that would hold it back, and expects `failed`: the adapter
|
|
4596
4612
|
is the second gate on a control on the contact, and one that applies it where there is nothing to
|