@xema/omni-protocol 0.1.16 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.d.ts +59 -1
- package/dist/testing.d.ts +31 -1
- package/dist/testing.js +210 -2
- package/dist/validation.d.ts +6 -0
- package/dist/validation.js +66 -0
- package/guide.md +113 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,6 +65,7 @@ clean result is read for what it covers and not for the whole contract; `assertR
|
|
|
65
65
|
subjects)` is the paired assertion.
|
|
66
66
|
|
|
67
67
|
```ts
|
|
68
|
+
const context = { protocolVersion: OMNI_PROTOCOL_VERSION, sessionId: "session-1", host: stillHost(report) };
|
|
68
69
|
const result = await exerciseAdapter(adapter, context, { collectOnly: true });
|
|
69
70
|
expect(result.violations).toEqual([]);
|
|
70
71
|
expect(result.disconnectWasClean).toBe(true);
|
package/dist/index.d.ts
CHANGED
|
@@ -247,12 +247,69 @@ export interface AuthenticationSession {
|
|
|
247
247
|
signOut(): Promise<AuthenticationActionResult>;
|
|
248
248
|
close(): Promise<void>;
|
|
249
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* How the host's own audio stands: the microphone Omni captures for the agent, and whether it has
|
|
252
|
+
* it. Omni facilitates the microphone -- captures it, prompts, retries, tells the agent -- and
|
|
253
|
+
* does not decide for the adapter what a missing one means. The adapter does what its platform
|
|
254
|
+
* needs: go not-ready, refuse calls, or carry on because audio lands elsewhere.
|
|
255
|
+
*/
|
|
256
|
+
export type HostAudioInput =
|
|
257
|
+
/** Omni has the microphone. `flowing` is false while the hardware or OS says no audio moves through it. */
|
|
258
|
+
{
|
|
259
|
+
status: "ready";
|
|
260
|
+
localAudio: MediaStream;
|
|
261
|
+
flowing: boolean;
|
|
262
|
+
}
|
|
263
|
+
/** Omni does not, and `reason` says which fix the agent needs; `failure` is the words Omni showed them. */
|
|
264
|
+
| {
|
|
265
|
+
status: "unavailable";
|
|
266
|
+
reason: HostAudioUnavailableReason;
|
|
267
|
+
failure: ProtocolFailure;
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* Why the host has no microphone, each wanting a different fix from the agent: no device;
|
|
271
|
+
* permission refused; permission never asked for (a host that asks at connect never says this);
|
|
272
|
+
* a device present and permitted that another application holds; a capture that ended.
|
|
273
|
+
*/
|
|
274
|
+
export type HostAudioUnavailableReason = "no-device" | "denied" | "not-asked" | "in-use" | "lost";
|
|
275
|
+
/** Why the host has no speaker: no device, or one that was removed. */
|
|
276
|
+
export type HostOutputUnavailableReason = "no-device" | "lost";
|
|
277
|
+
export type HostAudioOutput = {
|
|
278
|
+
status: "ready";
|
|
279
|
+
} | {
|
|
280
|
+
status: "unavailable";
|
|
281
|
+
reason: HostOutputUnavailableReason;
|
|
282
|
+
failure: ProtocolFailure;
|
|
283
|
+
};
|
|
284
|
+
/**
|
|
285
|
+
* What only the host can see about the agent's station: the devices, the permissions, the
|
|
286
|
+
* network. Omni reports it; the adapter decides what any of it means for its platform and what
|
|
287
|
+
* to relay. `audio` is present on a voice connection.
|
|
288
|
+
*/
|
|
289
|
+
export interface HostReport {
|
|
290
|
+
/** Whether the host has a network interface up. Not a claim that anything is reachable: the adapter knows its own platform's reachability better than the host does. */
|
|
291
|
+
online: boolean;
|
|
292
|
+
audio?: {
|
|
293
|
+
input: HostAudioInput;
|
|
294
|
+
output: HostAudioOutput;
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/** The host, as an adapter may ask it: a report now, and every change for the life of the connection. */
|
|
298
|
+
export interface Host {
|
|
299
|
+
report(): HostReport;
|
|
300
|
+
subscribe(listener: (report: HostReport) => void): Unsubscribe;
|
|
301
|
+
}
|
|
250
302
|
export interface ConnectContext {
|
|
251
303
|
protocolVersion: number;
|
|
252
304
|
/** The session that authenticated this connection. */
|
|
253
305
|
sessionId: string;
|
|
254
306
|
/** Omni-side policy: whether the agent's tasks are accepted without asking them. */
|
|
255
307
|
autoAcceptTasks?: boolean;
|
|
308
|
+
/**
|
|
309
|
+
* The host's report of the agent's station, to consult before declaring the agent ready to the
|
|
310
|
+
* platform and whenever it changes. Omni reports; the adapter decides.
|
|
311
|
+
*/
|
|
312
|
+
host: Host;
|
|
256
313
|
signal?: AbortSignal;
|
|
257
314
|
log?: (entry: unknown) => void;
|
|
258
315
|
}
|
|
@@ -791,7 +848,8 @@ export interface VoiceMediaSession {
|
|
|
791
848
|
}
|
|
792
849
|
export interface OpenMediaRequest {
|
|
793
850
|
taskId: TaskId;
|
|
794
|
-
localAudio
|
|
851
|
+
/** The agent's microphone as Omni captured it, `HostReport.audio.input.localAudio`; absent while that input is `unavailable`. */
|
|
852
|
+
localAudio?: MediaStream;
|
|
795
853
|
}
|
|
796
854
|
export type OpenMediaResult = {
|
|
797
855
|
status: "opened";
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type Manifest, type ProviderEvent } from "./index.js";
|
|
1
|
+
import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type Host, type HostReport, type Manifest, type ProviderEvent } from "./index.js";
|
|
2
2
|
import { type ProtocolViolation } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation } from "./validation.js";
|
|
4
4
|
/**
|
|
@@ -95,6 +95,36 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
|
|
|
95
95
|
* both.
|
|
96
96
|
*/
|
|
97
97
|
export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
|
|
98
|
+
/** What a stream has said about the tasks it carries, and the rules across events. */
|
|
99
|
+
export declare class TaskStream {
|
|
100
|
+
private readonly tasks;
|
|
101
|
+
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
102
|
+
seed(snapshot: unknown): void;
|
|
103
|
+
/** Applies one envelope and returns what it may not say given what came before. */
|
|
104
|
+
apply(envelope: unknown, path?: string): ProtocolViolation[];
|
|
105
|
+
}
|
|
106
|
+
/** What a stream has said about the agent's break, and the moves it may not make. */
|
|
107
|
+
export declare class BreakStream {
|
|
108
|
+
private approval;
|
|
109
|
+
/** Takes the break state a snapshot carries as the point the stream continues from. */
|
|
110
|
+
seed(snapshot: unknown): void;
|
|
111
|
+
/** Applies one envelope and returns the moves it may not make given where the break stood. */
|
|
112
|
+
apply(envelope: unknown, path?: string): ProtocolViolation[];
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A break follows its requests. Given a provider's stream -- optionally seeded with the snapshot
|
|
116
|
+
* it began from -- every `break-state` moves the way the guide describes: a commit's states only
|
|
117
|
+
* after a grant, never backwards, and a break placed on the agent arriving in effect with `imposed`.
|
|
118
|
+
*/
|
|
119
|
+
export declare function assertBreakFollowsItsRequests(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
|
|
120
|
+
/**
|
|
121
|
+
* The media follows the task and never decides it. Given a provider's stream -- optionally seeded
|
|
122
|
+
* with the snapshot it began from -- every task is introduced once, `task-media-ended` names a
|
|
123
|
+
* task whose work has begun, and what follows it is `completing` or `task-ended`.
|
|
124
|
+
*/
|
|
125
|
+
export declare function assertMediaFollowsTheTask(envelopes: readonly ProviderEventEnvelope[], snapshot?: Snapshot): void;
|
|
126
|
+
/** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
|
|
127
|
+
export declare function stillHost(report?: HostReport): Host;
|
|
98
128
|
/** One provider as the host sees it when freezing a break attempt's participant set. */
|
|
99
129
|
export interface BreakCandidate {
|
|
100
130
|
id: string;
|
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { browserSessionKey, sameCapabilities, } from "./index.js";
|
|
2
|
-
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateManifest, validateResult, validateSnapshot, } from "./validation.js";
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostReport, validateManifest, validateResult, validateSnapshot, } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
4
4
|
/**
|
|
5
5
|
* A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
|
|
@@ -142,6 +142,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
142
142
|
const violations = [...validateManifest(adapter.manifest)];
|
|
143
143
|
const events = [];
|
|
144
144
|
const seen = new Set();
|
|
145
|
+
const stream = new TaskStream();
|
|
146
|
+
const breaks = new BreakStream();
|
|
147
|
+
let seeded = false;
|
|
145
148
|
const storedSecrets = new Map();
|
|
146
149
|
const authentication = await adapter.createAuthenticationSession({
|
|
147
150
|
...context,
|
|
@@ -154,6 +157,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
154
157
|
let connection;
|
|
155
158
|
let unsubscribe;
|
|
156
159
|
let unsubscribeAuthentication;
|
|
160
|
+
let unsubscribeHost;
|
|
157
161
|
let authenticationState;
|
|
158
162
|
let login;
|
|
159
163
|
let disconnectWasClean = false;
|
|
@@ -224,7 +228,28 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
224
228
|
requireCapabilityMethods(connection, state.capabilities);
|
|
225
229
|
}
|
|
226
230
|
});
|
|
227
|
-
|
|
231
|
+
// The host's report is Omni's output, and a test that hands the adapter a malformed one is
|
|
232
|
+
// testing a host that cannot exist. Its first report and every later one are validated, a
|
|
233
|
+
// voice connection's host reports its audio and no other does, and the host the adapter
|
|
234
|
+
// receives is wrapped so the harness can tell whether the adapter ever asked.
|
|
235
|
+
const first = context.host.report();
|
|
236
|
+
violations.push(...validateHostReport(first, "context.host"));
|
|
237
|
+
const hasAudio = isRecord(first) && first.audio !== undefined;
|
|
238
|
+
if (adapter.manifest.channel === "voice" && !hasAudio) {
|
|
239
|
+
violations.push({ rule: "context.host.audio.required", path: "context.host.audio", message: "a voice connection's host reports its audio" });
|
|
240
|
+
}
|
|
241
|
+
if (adapter.manifest.channel !== "voice" && hasAudio) {
|
|
242
|
+
violations.push({ rule: "context.host.audio.unexpected", path: "context.host.audio", message: `a ${adapter.manifest.channel} connection has no audio for the host to report` });
|
|
243
|
+
}
|
|
244
|
+
unsubscribeHost = context.host.subscribe(report => {
|
|
245
|
+
violations.push(...validateHostReport(report, "context.host"));
|
|
246
|
+
});
|
|
247
|
+
let consulted = false;
|
|
248
|
+
const host = {
|
|
249
|
+
report: () => { consulted = true; return context.host.report(); },
|
|
250
|
+
subscribe: listener => { consulted = true; return context.host.subscribe(listener); },
|
|
251
|
+
};
|
|
252
|
+
connection = await adapter.connect({ ...context, host });
|
|
228
253
|
const live = connection;
|
|
229
254
|
// Dial is declared by presence: the capability object carries a destination policy rather
|
|
230
255
|
// than an `enabled` flag, so its presence is the declaration.
|
|
@@ -239,6 +264,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
239
264
|
violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader()));
|
|
240
265
|
if (eventNamesUsers(envelope))
|
|
241
266
|
requireMethod(live, "describeUsers", "an event publishes a UserId");
|
|
267
|
+
// Cross-event rules apply once the stream has a beginning: the connect snapshot.
|
|
268
|
+
if (seeded)
|
|
269
|
+
violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
|
|
242
270
|
if (typeof envelope?.id === "string") {
|
|
243
271
|
if (eventIds.has(envelope.id))
|
|
244
272
|
return;
|
|
@@ -249,11 +277,23 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
249
277
|
const snapshot = await connection.snapshot();
|
|
250
278
|
observeSnapshot(snapshot, seen);
|
|
251
279
|
violations.push(...validateSnapshot(snapshot, adapter.manifest, "snapshot", reader()));
|
|
280
|
+
stream.seed(snapshot);
|
|
281
|
+
breaks.seed(snapshot);
|
|
282
|
+
seeded = true;
|
|
252
283
|
requireCapabilityMethods(live, current().capabilities);
|
|
253
284
|
if (publishesUserIds(snapshot))
|
|
254
285
|
requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
|
|
255
286
|
// Capacity is stated, not requested: nothing may be allocated until it is, so a connection
|
|
256
287
|
// that will not accept one is a connection nothing can be given to.
|
|
288
|
+
// The guide's obligation on a voice adapter: consult the host before declaring the agent
|
|
289
|
+
// ready, and on every change. An adapter that never asked cannot have.
|
|
290
|
+
if (adapter.manifest.channel === "voice" && !consulted) {
|
|
291
|
+
violations.push({
|
|
292
|
+
rule: "connection.host.consulted",
|
|
293
|
+
path: "connection.host",
|
|
294
|
+
message: "a voice adapter consults the host's report before declaring the agent ready, and this one never asked",
|
|
295
|
+
});
|
|
296
|
+
}
|
|
257
297
|
const capacity = await connection.setCapacity({ count: 1 });
|
|
258
298
|
const malformed = validateResult(capacity, "setCapacity", "connection.setCapacity");
|
|
259
299
|
violations.push(...malformed);
|
|
@@ -280,6 +320,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
280
320
|
catch {
|
|
281
321
|
clean = false;
|
|
282
322
|
}
|
|
323
|
+
try {
|
|
324
|
+
unsubscribeHost?.();
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
clean = false;
|
|
328
|
+
}
|
|
283
329
|
try {
|
|
284
330
|
await connection?.disconnect();
|
|
285
331
|
}
|
|
@@ -518,6 +564,168 @@ export function assertDeniedAndRetriedBreak(approvals) {
|
|
|
518
564
|
throw new Error(`Break retry scenario must end granted or in effect, ended ${String(last)}`);
|
|
519
565
|
}
|
|
520
566
|
}
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
// The task stream. Each event is validated on its own; what one event may say about a task
|
|
569
|
+
// depends on what was said before, and only something that watched the whole stream can hold a
|
|
570
|
+
// provider to it. A task is never its audio: media ends only on work that has begun, and what
|
|
571
|
+
// follows the media ending is the work completing or ending, never a phase the audio decided.
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
const WORK_BEGUN = new Set(["in-progress", "paused", "completing"]);
|
|
574
|
+
/** What a stream has said about the tasks it carries, and the rules across events. */
|
|
575
|
+
export class TaskStream {
|
|
576
|
+
tasks = new Map();
|
|
577
|
+
/** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
|
|
578
|
+
seed(snapshot) {
|
|
579
|
+
this.tasks.clear();
|
|
580
|
+
if (!isRecord(snapshot) || !Array.isArray(snapshot.tasks))
|
|
581
|
+
return;
|
|
582
|
+
for (const task of snapshot.tasks) {
|
|
583
|
+
if (isRecord(task) && typeof task.id === "string")
|
|
584
|
+
this.tasks.set(task.id, { phase: String(task.phase), mediaEnded: false });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/** Applies one envelope and returns what it may not say given what came before. */
|
|
588
|
+
apply(envelope, path = "event") {
|
|
589
|
+
const found = [];
|
|
590
|
+
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
591
|
+
if (!isRecord(event))
|
|
592
|
+
return found;
|
|
593
|
+
const at = `${path}.event`;
|
|
594
|
+
const refuse = (rule, where, message) => found.push({ rule, path: where, message });
|
|
595
|
+
const id = typeof event.taskId === "string" ? event.taskId : isRecord(event.task) && typeof event.task.id === "string" ? event.task.id : undefined;
|
|
596
|
+
const known = id === undefined ? undefined : this.tasks.get(id);
|
|
597
|
+
switch (event.type) {
|
|
598
|
+
case "snapshot":
|
|
599
|
+
this.seed(event.snapshot);
|
|
600
|
+
break;
|
|
601
|
+
case "task-offered":
|
|
602
|
+
if (id === undefined)
|
|
603
|
+
break;
|
|
604
|
+
if (known !== undefined)
|
|
605
|
+
refuse("stream.taskOffered.duplicate", `${at}.task.id`, `${id} is already on the stream; an offer introduces a task once`);
|
|
606
|
+
this.tasks.set(id, { phase: String(isRecord(event.task) ? event.task.phase : undefined), mediaEnded: false });
|
|
607
|
+
break;
|
|
608
|
+
case "task-updated":
|
|
609
|
+
if (id === undefined)
|
|
610
|
+
break;
|
|
611
|
+
if (known === undefined) {
|
|
612
|
+
refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
if (known.mediaEnded) {
|
|
616
|
+
const phase = isRecord(event.task) ? String(event.task.phase) : "";
|
|
617
|
+
if (phase !== "completing") {
|
|
618
|
+
refuse("stream.taskMediaEnded.follow", `${at}.task.phase`, `after its media ended, ${id} completes or ends; ${phase} is a phase the audio does not decide`);
|
|
619
|
+
}
|
|
620
|
+
known.mediaEnded = phase === "completing" ? false : known.mediaEnded;
|
|
621
|
+
}
|
|
622
|
+
known.phase = isRecord(event.task) ? String(event.task.phase) : known.phase;
|
|
623
|
+
break;
|
|
624
|
+
case "task-media-ended":
|
|
625
|
+
if (id === undefined)
|
|
626
|
+
break;
|
|
627
|
+
if (known === undefined) {
|
|
628
|
+
refuse("stream.taskMediaEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
if (!WORK_BEGUN.has(known.phase)) {
|
|
632
|
+
refuse("stream.taskMediaEnded.beforeWork", `${at}.taskId`, `media cannot end on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
|
|
633
|
+
}
|
|
634
|
+
known.mediaEnded = true;
|
|
635
|
+
break;
|
|
636
|
+
case "task-ended":
|
|
637
|
+
if (id === undefined)
|
|
638
|
+
break;
|
|
639
|
+
if (known === undefined)
|
|
640
|
+
refuse("stream.taskEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
|
|
641
|
+
this.tasks.delete(id);
|
|
642
|
+
break;
|
|
643
|
+
default:
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
return found;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// A request goes not-requested -> awaiting-decision | granted; a commit goes granted ->
|
|
650
|
+
// starting-after-task | in-effect; work ending goes starting-after-task -> in-effect; a denial,
|
|
651
|
+
// a cancel, an end or a release goes back to not-requested; a placed break arrives in-effect
|
|
652
|
+
// with `imposed`. Nothing else is a move the guide describes.
|
|
653
|
+
const COMMITTED = new Set(["starting-after-task", "in-effect"]);
|
|
654
|
+
const BACKWARDS = {
|
|
655
|
+
"in-effect": ["awaiting-decision", "granted", "starting-after-task"],
|
|
656
|
+
"starting-after-task": ["awaiting-decision", "granted"],
|
|
657
|
+
granted: ["awaiting-decision"],
|
|
658
|
+
};
|
|
659
|
+
/** What a stream has said about the agent's break, and the moves it may not make. */
|
|
660
|
+
export class BreakStream {
|
|
661
|
+
approval;
|
|
662
|
+
/** Takes the break state a snapshot carries as the point the stream continues from. */
|
|
663
|
+
seed(snapshot) {
|
|
664
|
+
const state = isRecord(snapshot) ? snapshot.break : undefined;
|
|
665
|
+
this.approval = isRecord(state) && typeof state.approval === "string" ? state.approval : undefined;
|
|
666
|
+
}
|
|
667
|
+
/** Applies one envelope and returns the moves it may not make given where the break stood. */
|
|
668
|
+
apply(envelope, path = "event") {
|
|
669
|
+
const found = [];
|
|
670
|
+
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
671
|
+
if (!isRecord(event))
|
|
672
|
+
return found;
|
|
673
|
+
if (event.type === "snapshot") {
|
|
674
|
+
this.seed(event.snapshot);
|
|
675
|
+
return found;
|
|
676
|
+
}
|
|
677
|
+
if (event.type !== "break-state" || !isRecord(event.break) || typeof event.break.approval !== "string")
|
|
678
|
+
return found;
|
|
679
|
+
const from = this.approval;
|
|
680
|
+
const to = event.break.approval;
|
|
681
|
+
const at = `${path}.event.break.approval`;
|
|
682
|
+
if (from !== undefined) {
|
|
683
|
+
// A commit's states need a grant behind them. A placed break is the one arrival in a
|
|
684
|
+
// committed state that nobody asked for -- in effect at once, or starting-after-task while
|
|
685
|
+
// the member finishes a call -- and it says so with `imposed`.
|
|
686
|
+
if (COMMITTED.has(to) && (from === "not-requested" || from === "awaiting-decision") && event.break.imposed === undefined) {
|
|
687
|
+
found.push({ rule: "stream.breakState.commitBeforeGrant", path: at,
|
|
688
|
+
message: `${to} follows a commit, and a commit follows granted; the break stood at ${from}` });
|
|
689
|
+
}
|
|
690
|
+
if ((BACKWARDS[from] ?? []).includes(to)) {
|
|
691
|
+
found.push({ rule: "stream.breakState.backwards", path: at,
|
|
692
|
+
message: `a break does not go from ${from} back to ${to}; a new request passes through not-requested` });
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
this.approval = to;
|
|
696
|
+
return found;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* A break follows its requests. Given a provider's stream -- optionally seeded with the snapshot
|
|
701
|
+
* it began from -- every `break-state` moves the way the guide describes: a commit's states only
|
|
702
|
+
* after a grant, never backwards, and a break placed on the agent arriving in effect with `imposed`.
|
|
703
|
+
*/
|
|
704
|
+
export function assertBreakFollowsItsRequests(envelopes, snapshot) {
|
|
705
|
+
const stream = new BreakStream();
|
|
706
|
+
if (snapshot !== undefined)
|
|
707
|
+
stream.seed(snapshot);
|
|
708
|
+
const found = [];
|
|
709
|
+
envelopes.forEach((envelope, index) => found.push(...stream.apply(envelope, `envelopes[${index}]`)));
|
|
710
|
+
assertNoViolations(found, "A break follows its requests");
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* The media follows the task and never decides it. Given a provider's stream -- optionally seeded
|
|
714
|
+
* with the snapshot it began from -- every task is introduced once, `task-media-ended` names a
|
|
715
|
+
* task whose work has begun, and what follows it is `completing` or `task-ended`.
|
|
716
|
+
*/
|
|
717
|
+
export function assertMediaFollowsTheTask(envelopes, snapshot) {
|
|
718
|
+
const stream = new TaskStream();
|
|
719
|
+
if (snapshot !== undefined)
|
|
720
|
+
stream.seed(snapshot);
|
|
721
|
+
const found = [];
|
|
722
|
+
envelopes.forEach((envelope, index) => found.push(...stream.apply(envelope, `envelopes[${index}]`)));
|
|
723
|
+
assertNoViolations(found, "The media follows the task");
|
|
724
|
+
}
|
|
725
|
+
/** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
|
|
726
|
+
export function stillHost(report = { online: true }) {
|
|
727
|
+
return { report: () => report, subscribe: () => () => undefined };
|
|
728
|
+
}
|
|
521
729
|
const usableLogin = (status) => status === "authenticated" || status === "refreshing";
|
|
522
730
|
/**
|
|
523
731
|
* The participant set of a break attempt is every connected provider from which the agent can
|
package/dist/validation.d.ts
CHANGED
|
@@ -38,6 +38,12 @@ export interface ReaderContext {
|
|
|
38
38
|
export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
39
39
|
export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
40
40
|
export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
41
|
+
/**
|
|
42
|
+
* Validates the host's report as Omni publishes it to an adapter. This is Omni's output, so the
|
|
43
|
+
* check belongs to the host's own tests and to the harness, which validates whatever host a test
|
|
44
|
+
* hands the adapter.
|
|
45
|
+
*/
|
|
46
|
+
export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
|
|
41
47
|
/** The connection methods whose results `validateResult` knows. */
|
|
42
48
|
export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia";
|
|
43
49
|
/**
|
package/dist/validation.js
CHANGED
|
@@ -1144,6 +1144,72 @@ function validateFailureInto(value, path, into) {
|
|
|
1144
1144
|
into.require(typeof value.retryAfterMs === "number" && Number.isFinite(value.retryAfterMs) && value.retryAfterMs >= 0, "failure.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
|
|
1145
1145
|
}
|
|
1146
1146
|
}
|
|
1147
|
+
const HOST_AUDIO_REASONS = membersOf({ "no-device": true, denied: true, "not-asked": true, "in-use": true, lost: true });
|
|
1148
|
+
const HOST_OUTPUT_REASONS = membersOf({ "no-device": true, lost: true });
|
|
1149
|
+
function validateUnavailable(value, rule, path, into) {
|
|
1150
|
+
if (value.failure === undefined) {
|
|
1151
|
+
into.add(`${rule}.failure.required`, `${path}.failure`, "unavailable carries the failure that says why");
|
|
1152
|
+
}
|
|
1153
|
+
else {
|
|
1154
|
+
validateFailureInto(value.failure, `${path}.failure`, into);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Validates the host's report as Omni publishes it to an adapter. This is Omni's output, so the
|
|
1159
|
+
* check belongs to the host's own tests and to the harness, which validates whatever host a test
|
|
1160
|
+
* hands the adapter.
|
|
1161
|
+
*/
|
|
1162
|
+
export function validateHostReport(report, path = "host") {
|
|
1163
|
+
const into = new Collector();
|
|
1164
|
+
if (!isPlainObject(report)) {
|
|
1165
|
+
into.add("host.shape", path, "a host report must be an object");
|
|
1166
|
+
return into.violations;
|
|
1167
|
+
}
|
|
1168
|
+
into.require(typeof report.online === "boolean", "host.online", `${path}.online`, "a host report says whether it has a network");
|
|
1169
|
+
if (report.audio === undefined)
|
|
1170
|
+
return into.violations;
|
|
1171
|
+
if (!isPlainObject(report.audio)) {
|
|
1172
|
+
into.add("host.audio.shape", `${path}.audio`, "audio must be an object when present, with input and output");
|
|
1173
|
+
return into.violations;
|
|
1174
|
+
}
|
|
1175
|
+
const input = report.audio.input;
|
|
1176
|
+
const at = `${path}.audio.input`;
|
|
1177
|
+
if (!isPlainObject(input)) {
|
|
1178
|
+
into.add("host.audio.input.shape", at, "audio carries its input");
|
|
1179
|
+
}
|
|
1180
|
+
else if (input.status === "ready") {
|
|
1181
|
+
into.require(typeof input.localAudio === "object" && input.localAudio !== null, "host.audio.input.localAudio", `${at}.localAudio`, "a ready input carries the captured microphone");
|
|
1182
|
+
into.require(typeof input.flowing === "boolean", "host.audio.input.flowing", `${at}.flowing`, "a ready input says whether audio is flowing through it");
|
|
1183
|
+
into.require(input.failure === undefined, "host.audio.input.failure.unexpected", `${at}.failure`, "a ready input carries no failure");
|
|
1184
|
+
into.require(input.reason === undefined, "host.audio.input.reason.unexpected", `${at}.reason`, "a ready input has no reason to be unavailable");
|
|
1185
|
+
}
|
|
1186
|
+
else if (input.status === "unavailable") {
|
|
1187
|
+
into.oneOf(input.reason, HOST_AUDIO_REASONS, "host.audio.input.reason", `${at}.reason`);
|
|
1188
|
+
validateUnavailable(input, "host.audio.input", at, into);
|
|
1189
|
+
into.require(input.localAudio === undefined, "host.audio.input.localAudio.unexpected", `${at}.localAudio`, "an unavailable input carries no microphone");
|
|
1190
|
+
into.require(input.flowing === undefined, "host.audio.input.flowing.unexpected", `${at}.flowing`, "an unavailable input has nothing to flow");
|
|
1191
|
+
}
|
|
1192
|
+
else {
|
|
1193
|
+
into.add("host.audio.input.status", `${at}.status`, `an input is ready or unavailable, not ${String(input.status)}`);
|
|
1194
|
+
}
|
|
1195
|
+
const output = report.audio.output;
|
|
1196
|
+
const out = `${path}.audio.output`;
|
|
1197
|
+
if (!isPlainObject(output)) {
|
|
1198
|
+
into.add("host.audio.output.shape", out, "audio carries its output");
|
|
1199
|
+
}
|
|
1200
|
+
else if (output.status === "ready") {
|
|
1201
|
+
into.require(output.failure === undefined, "host.audio.output.failure.unexpected", `${out}.failure`, "a ready output carries no failure");
|
|
1202
|
+
into.require(output.reason === undefined, "host.audio.output.reason.unexpected", `${out}.reason`, "a ready output has no reason to be unavailable");
|
|
1203
|
+
}
|
|
1204
|
+
else if (output.status === "unavailable") {
|
|
1205
|
+
into.oneOf(output.reason, HOST_OUTPUT_REASONS, "host.audio.output.reason", `${out}.reason`);
|
|
1206
|
+
validateUnavailable(output, "host.audio.output", out, into);
|
|
1207
|
+
}
|
|
1208
|
+
else {
|
|
1209
|
+
into.add("host.audio.output.status", `${out}.status`, `an output is ready or unavailable, not ${String(output.status)}`);
|
|
1210
|
+
}
|
|
1211
|
+
return into.violations;
|
|
1212
|
+
}
|
|
1147
1213
|
// Pinned to the result unions: each method's one success status, and the status that carries a
|
|
1148
1214
|
// failure. A method added to `Connection` without a row here is a compile error at the call site.
|
|
1149
1215
|
const RESULT_STATUSES = {
|
package/guide.md
CHANGED
|
@@ -245,10 +245,35 @@ type AuthenticationFailure = {
|
|
|
245
245
|
field?: string;
|
|
246
246
|
};
|
|
247
247
|
|
|
248
|
+
type HostAudioUnavailableReason = "no-device" | "denied" | "not-asked" | "in-use" | "lost";
|
|
249
|
+
type HostOutputUnavailableReason = "no-device" | "lost";
|
|
250
|
+
|
|
251
|
+
type HostAudioInput =
|
|
252
|
+
| { status: "ready"; localAudio: MediaStream; flowing: boolean }
|
|
253
|
+
| { status: "unavailable"; reason: HostAudioUnavailableReason; failure: ProtocolFailure };
|
|
254
|
+
|
|
255
|
+
type HostAudioOutput =
|
|
256
|
+
| { status: "ready" }
|
|
257
|
+
| { status: "unavailable"; reason: HostOutputUnavailableReason; failure: ProtocolFailure };
|
|
258
|
+
|
|
259
|
+
type HostReport = {
|
|
260
|
+
online: boolean;
|
|
261
|
+
audio?: {
|
|
262
|
+
input: HostAudioInput;
|
|
263
|
+
output: HostAudioOutput;
|
|
264
|
+
};
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
type Host = {
|
|
268
|
+
report(): HostReport;
|
|
269
|
+
subscribe(listener: (report: HostReport) => void): Unsubscribe;
|
|
270
|
+
};
|
|
271
|
+
|
|
248
272
|
type ConnectContext = {
|
|
249
273
|
protocolVersion: number;
|
|
250
274
|
sessionId: string;
|
|
251
275
|
autoAcceptTasks?: boolean;
|
|
276
|
+
host: Host;
|
|
252
277
|
signal?: AbortSignal;
|
|
253
278
|
log?: (entry: unknown) => void;
|
|
254
279
|
};
|
|
@@ -1491,6 +1516,7 @@ Creates one live provider connection for the signed-in agent.
|
|
|
1491
1516
|
| `protocolVersion` | Version negotiated before authentication. Fixed for this login. |
|
|
1492
1517
|
| `sessionId` | Omni-generated identity for this login. It is the same value passed as `AuthenticationContext.sessionId`, so an adapter can correlate this connection with the session that authenticated it. Stable across transport reconnects and changed only by a new login. |
|
|
1493
1518
|
| `autoAcceptTasks` | Agent provisioning policy relayed to the provider at login. Treated as `true` when omitted. When `true`, `task-offered` carries an `acceptanceMode`; when `false`, every task requires agent acceptance. |
|
|
1519
|
+
| `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**. |
|
|
1494
1520
|
| `signal` | Optional cancellation signal. Stop startup promptly when aborted and do not begin new work. |
|
|
1495
1521
|
| `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
|
|
1496
1522
|
|
|
@@ -1671,6 +1697,7 @@ automatic or requires the agent. A provider that requires automatic acceptance s
|
|
|
1671
1697
|
declare const task: Task;
|
|
1672
1698
|
|
|
1673
1699
|
const allocation = {
|
|
1700
|
+
type: "task-offered",
|
|
1674
1701
|
task,
|
|
1675
1702
|
acceptanceMode: "require-agent-acceptance",
|
|
1676
1703
|
allocationExpiresAt: "2026-08-25T10:41:07.000Z",
|
|
@@ -1789,6 +1816,25 @@ allocation's `acceptanceMode`, moving the task from `pending` to `confirmed`. Th
|
|
|
1789
1816
|
subsequent transitions to `preparing` or `in-progress`; Omni does not infer them from the acceptance
|
|
1790
1817
|
command.
|
|
1791
1818
|
|
|
1819
|
+
**A task is never its audio.** A voice task is the allocation: the call is offered when it is
|
|
1820
|
+
routed to the agent and accepted as `acceptanceMode` dictates, and its presence and phase follow
|
|
1821
|
+
the provider's reports about the work — never the audio. Wherever audio moves — an offer, a hold, a
|
|
1822
|
+
consult, a conference leg joining or leaving, a transfer, a callback — the media follows
|
|
1823
|
+
separately, attaching through `openMedia` and ending with `task-media-ended`. Omni does not ring,
|
|
1824
|
+
bridge, or hold a line. How the phone rings, whether it rings at all, and where legs join and leave
|
|
1825
|
+
are the adapter's and the platform's, transient, and decide neither when a task exists nor what
|
|
1826
|
+
phase it is in.
|
|
1827
|
+
|
|
1828
|
+
The line runs between the provider's word and Omni's own senses. `task-media-ended` is the
|
|
1829
|
+
provider's report that primary handling ended — a fact about the work, which is why the completion
|
|
1830
|
+
allowance starts on it and the callback control appears on it — and Omni follows that report as it
|
|
1831
|
+
follows any other. What Omni never does is derive a task's state from its own media session: a
|
|
1832
|
+
stream that drops, a track that ends, a transport that disconnects, a microphone that fails, an
|
|
1833
|
+
endpoint re-registering change nothing about the task until the provider says so. Structurally:
|
|
1834
|
+
`task-media-ended` names a task whose work has begun, what follows it is `completing` or
|
|
1835
|
+
`task-ended`, and every task is introduced once — `exerciseAdapter` holds the stream to that from
|
|
1836
|
+
the connect snapshot on, and `assertMediaFollowsTheTask` holds any sequence.
|
|
1837
|
+
|
|
1792
1838
|
#### Completion timing
|
|
1793
1839
|
|
|
1794
1840
|
`completionMode` determines how completion is triggered. With `agent-command`, the provider keeps
|
|
@@ -2796,26 +2842,61 @@ Omni, and Omni registers the endpoint for it.
|
|
|
2796
2842
|
|
|
2797
2843
|
That removes a whole class of state the provider would otherwise own and Omni would have to track,
|
|
2798
2844
|
and it removes the branch that came with it: no command has to ask where the audio went before
|
|
2799
|
-
deciding who performs it.
|
|
2845
|
+
deciding who performs it. Nor does the audio ever stand in for the task: a task's presence and
|
|
2846
|
+
phase follow the provider's reports about the work, and the media — attaching, moving through a
|
|
2847
|
+
hold, a consult, a conference or a transfer, and ending — is transient beside it. See **A task is
|
|
2848
|
+
never its audio** under **Task allocation lifecycle**.
|
|
2849
|
+
|
|
2850
|
+
### The host reports, the adapter decides
|
|
2851
|
+
|
|
2852
|
+
The adapter runs inside Omni and sees nothing of the station for itself: the devices, the
|
|
2853
|
+
permissions, the audio element and the network are the host's, and only the host can tell. So
|
|
2854
|
+
the host reports, and the adapter asks. **An adapter consults `ConnectContext.host` before it
|
|
2855
|
+
declares the agent ready to its platform, and again whenever the report changes**, and it decides
|
|
2856
|
+
what any of it means and what to relay upstream — go not-ready, refuse calls, carry on because the
|
|
2857
|
+
platform's audio lands elsewhere. Omni facilitates and does not take responsibility: it captures
|
|
2858
|
+
the microphone once as the voice connection opens, so the permission prompt lands while the agent
|
|
2859
|
+
is signing in rather than over a contact, prompts, retries on the agent's request, tells the agent
|
|
2860
|
+
what failed, and reports. It never decides for the adapter what a missing microphone means.
|
|
2861
|
+
|
|
2862
|
+
| Field | Contract |
|
|
2863
|
+
| --- | --- |
|
|
2864
|
+
| `online` | Whether the host has a network interface up. Not a claim that anything is reachable — the adapter knows whether it can reach its own platform far better than the host does — so `false` is a reason not to go ready and `true` is not a reason to. |
|
|
2865
|
+
| `audio` | Present on a voice connection, absent where there is no audio. |
|
|
2866
|
+
| `audio.input` | `ready` with `localAudio` — the microphone as captured, the same stream `openMedia` receives — and `flowing`, false while the hardware or OS says no audio moves through it (a headset's own mute switch, which Omni's Mute control never touches). `unavailable` with `reason`, since each wants a different fix from the agent: `no-device`; `denied`; `not-asked`, which a host that asks at connect never publishes; `in-use`, a device present and permitted that another application holds — on an agent desktop the commonest of all; `lost`, a capture that ended. `failure` carries the words Omni showed them. |
|
|
2867
|
+
| `audio.output` | `ready`, or `unavailable` with `reason` — `no-device`, or `lost` for one removed — and `failure`: an agent who cannot hear is as unable to take a call as one who cannot speak. |
|
|
2868
|
+
|
|
2869
|
+
Omni republishes the report whenever it changes — a permission granted late, a headset unplugged,
|
|
2870
|
+
a network gone — and it publishes a state, not a flicker: a change that resolves within moments is
|
|
2871
|
+
not reported, so an adapter may act on what it reads without debouncing it again. Nothing in the
|
|
2872
|
+
report is a task fact or a capacity fact; it is what the station can do, and the adapter's platform
|
|
2873
|
+
is the one that knows whether an agent without a microphone, or without a speaker, can work.
|
|
2874
|
+
|
|
2875
|
+
The host's own obligations here — asking at connect, never publishing `not-asked` when it does,
|
|
2876
|
+
publishing a state and not a flicker — are Omni's tests' to hold. `exerciseAdapter` holds the
|
|
2877
|
+
other side: it validates the shape of whatever host a test hands the adapter, requires `audio` on
|
|
2878
|
+
a voice connection and none elsewhere (`context.host.audio.required` / `.unexpected`), and
|
|
2879
|
+
refuses a voice adapter that never asked the host anything (`connection.host.consulted`).
|
|
2800
2880
|
|
|
2801
2881
|
### Capacity around setup
|
|
2802
2882
|
|
|
2803
2883
|
Connecting is not the same as being able to take a call. A provider that treats a live connection
|
|
2804
|
-
as reachability opens a window where it believes the agent is available and
|
|
2805
|
-
|
|
2884
|
+
as reachability opens a window where it believes the agent is available and the agent is not yet
|
|
2885
|
+
set up — the adapter's own registration incomplete, its credentials not yet renewed.
|
|
2806
2886
|
|
|
2807
|
-
Nothing closes that window, because nothing opens it: **Omni states no capacity until the
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2887
|
+
Nothing closes that window, because nothing opens it: **Omni states no capacity until the
|
|
2888
|
+
connection is established**, and **Work is pulled, never pushed** makes an allocation with none
|
|
2889
|
+
stated a violation. Capacity does not wait on the microphone: whether an agent without host audio
|
|
2890
|
+
can take calls is the platform's question, answered by the adapter from the host's report, not by
|
|
2891
|
+
Omni withholding capacity for every platform alike.
|
|
2811
2892
|
|
|
2812
|
-
Capacity follows **automatically** once
|
|
2813
|
-
become available.
|
|
2893
|
+
Capacity follows **automatically** once the connection is established; the agent does not press
|
|
2894
|
+
anything to become available.
|
|
2814
2895
|
|
|
2815
2896
|
| Situation | What Omni sends |
|
|
2816
2897
|
| --- | --- |
|
|
2817
|
-
|
|
|
2818
|
-
|
|
|
2898
|
+
| Connecting | Nothing. No capacity has been stated, so nothing may be allocated. |
|
|
2899
|
+
| Connected and idle | `setCapacity({ count: n })`, with the host's report already available to consult. |
|
|
2819
2900
|
| A task starts or ends | Nothing. The provider counts its own against the ceiling. |
|
|
2820
2901
|
| The agent's provisioned capacity changes | `setCapacity({ count: n })` |
|
|
2821
2902
|
| Agent asks for a break | `requestBreak`. Capacity is unchanged and work continues. |
|
|
@@ -2841,7 +2922,9 @@ The adapter speaks whatever its platform speaks — SIP over WebSocket, a vendor
|
|
|
2841
2922
|
WebRTC — and **none of that appears in this contract**. Registration, signalling, credential
|
|
2842
2923
|
renewal and reconnect are the adapter's, exactly as its authentication and transport already
|
|
2843
2924
|
are. Omni owns what belongs to the host: the microphone, the output element, mute, and when a
|
|
2844
|
-
session ends
|
|
2925
|
+
session ends — owning the microphone meaning capturing it, prompting, retrying and reporting how
|
|
2926
|
+
it stands, never deciding for the adapter what a missing one means (see **The host reports, the
|
|
2927
|
+
adapter decides**).
|
|
2845
2928
|
|
|
2846
2929
|
| Member | Contract |
|
|
2847
2930
|
| --- | --- |
|
|
@@ -2849,9 +2932,10 @@ session ends.
|
|
|
2849
2932
|
| `setMuted(muted)` | Mutes the agent's microphone on this session. |
|
|
2850
2933
|
| `close()` | Releases the session. Omni calls it when the task ends. |
|
|
2851
2934
|
|
|
2852
|
-
`localAudio` is the agent's microphone
|
|
2853
|
-
|
|
2854
|
-
|
|
2935
|
+
`localAudio` is the agent's microphone as Omni captured it, the same stream the host's report
|
|
2936
|
+
carries as `audio.input.localAudio`, and absent while that input is `unavailable`. A provider that
|
|
2937
|
+
bridges audio without a host-side input may ignore it; one that needs it and finds it absent
|
|
2938
|
+
answers `unavailable` with a failure Omni shows the agent.
|
|
2855
2939
|
|
|
2856
2940
|
**A task-scoped session does not oblige one call per task.** A platform holding a nailed-up
|
|
2857
2941
|
leg for a whole shift may return the same session for every task and release the underlying
|
|
@@ -3064,6 +3148,15 @@ Replaces this provider's complete `break` object. Its `approval` uses the canoni
|
|
|
3064
3148
|
`in-effect` states defined under Breaks; the event also carries the corresponding accepting state,
|
|
3065
3149
|
reasons, retry details, and any imposed break.
|
|
3066
3150
|
|
|
3151
|
+
Each state is also held to the one before it. A commit's states, `starting-after-task` and
|
|
3152
|
+
`in-effect`, follow a grant — the one arrival in a committed state nobody asked for is a placed
|
|
3153
|
+
break, which says so with `imposed`: in effect at once, or `starting-after-task` while the member
|
|
3154
|
+
finishes the call they are on (`stream.breakState.commitBeforeGrant`); and a break never moves backwards —
|
|
3155
|
+
from `in-effect` or `starting-after-task` to a grant or a request, or from `granted` to
|
|
3156
|
+
`awaiting-decision` — a new request passes through `not-requested` (`stream.breakState.backwards`).
|
|
3157
|
+
`exerciseAdapter` holds the stream to that from the connect snapshot on;
|
|
3158
|
+
`assertBreakFollowsItsRequests` holds any sequence.
|
|
3159
|
+
|
|
3067
3160
|
For a multi-provider attempt, "every provider" is the participant set frozen when the attempt
|
|
3068
3161
|
entered `requesting-break`. Omni commits only after every participant reports `granted` —
|
|
3069
3162
|
that one is unconditional, because nothing has stopped yet and waiting costs only time. It enters
|
|
@@ -3181,6 +3274,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
3181
3274
|
| `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
|
|
3182
3275
|
| `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
|
|
3183
3276
|
| `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
|
|
3277
|
+
| `validateHostReport(report)` | The host's own report as published to an adapter: `online`, and where there is audio, an input that is `ready` with the microphone and `flowing`, or `unavailable` with a reason and the failure that says why, and an output that is `ready` or `unavailable` with its failure. The harness validates whatever host a test hands the adapter; `stillHost(report)` builds one that never changes. |
|
|
3184
3278
|
| `validateResult(result, method)` | What a connection method answered: the status it gives, a failure where the status says so and nowhere else, the failure's shape, and that an `omni.` code is one this contract names. |
|
|
3185
3279
|
| `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. |
|
|
3186
3280
|
|
|
@@ -3267,6 +3361,10 @@ cannot be established from TypeScript structure alone.
|
|
|
3267
3361
|
| `assertReached(result, subjects)` | The exercise met every subject named; throws listing those it did not. Pair it with a clean `exerciseAdapter` result. |
|
|
3268
3362
|
| `assertAuthenticationRestoreAndExpiry(states)` | A restored authenticated session can refresh and ends in expiry. Every state is validated. |
|
|
3269
3363
|
| `assertReconnectWithMissedAssignments(before, reconnect, ids)` | A reconnect snapshot restores assignments received while offline. |
|
|
3364
|
+
| `stillHost(report?)` | A host that reports one thing and never changes, for a test context: `{ online: true }` by default, a report with audio for a voice adapter. |
|
|
3365
|
+
| `TaskStream`, `BreakStream` | The cross-event models the harness applies after the connect snapshot, exported for a host that wants the same rules at its boundary: `seed(snapshot)`, then `apply(envelope)` returns the violations. |
|
|
3366
|
+
| `assertBreakFollowsItsRequests(envelopes, snapshot?)` | A break follows its requests: a commit's states only after a grant, never backwards, and a placed break arriving in effect with `imposed`. The harness applies the same rules after the connect snapshot. |
|
|
3367
|
+
| `assertMediaFollowsTheTask(envelopes, snapshot?)` | The media follows the task and never decides it: every task is introduced once, `task-media-ended` names a task whose work has begun, and what follows it is `completing` or `task-ended`. The harness applies the same rules to every event after the connect snapshot (`stream.*`). A sequence with no media satisfies it by never testing it — pair it with the assertion that the media end is present. |
|
|
3270
3368
|
| `assertBreakParticipants(candidates, participants)` | A break attempt asks every usable provider holding capacity, `refreshing` included, and nothing of a provider whose login is `expired`. |
|
|
3271
3369
|
| `assertBreakBeginsAfterTask(steps)` | A break asked for on a task is committed as `starting-after-task` while work remains and reaches `in-effect` only once nothing is outstanding — never beside a task, never later than the step that has none. |
|
|
3272
3370
|
| `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
|