@xema/omni-protocol 0.1.17 → 0.1.19
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 +45 -8
- package/dist/testing.d.ts +17 -1
- package/dist/testing.js +103 -12
- package/dist/validation.d.ts +4 -4
- package/dist/validation.js +55 -18
- package/guide.md +210 -36
- 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
|
@@ -253,17 +253,51 @@ export interface AuthenticationSession {
|
|
|
253
253
|
* does not decide for the adapter what a missing one means. The adapter does what its platform
|
|
254
254
|
* needs: go not-ready, refuse calls, or carry on because audio lands elsewhere.
|
|
255
255
|
*/
|
|
256
|
-
export type
|
|
256
|
+
export type HostAudioInput =
|
|
257
|
+
/** Omni has the microphone. `flowing` is false while the hardware or OS says no audio moves through it. */
|
|
258
|
+
{
|
|
257
259
|
status: "ready";
|
|
258
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";
|
|
259
279
|
} | {
|
|
260
280
|
status: "unavailable";
|
|
281
|
+
reason: HostOutputUnavailableReason;
|
|
261
282
|
failure: ProtocolFailure;
|
|
262
283
|
};
|
|
263
|
-
/**
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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;
|
|
267
301
|
}
|
|
268
302
|
export interface ConnectContext {
|
|
269
303
|
protocolVersion: number;
|
|
@@ -271,8 +305,11 @@ export interface ConnectContext {
|
|
|
271
305
|
sessionId: string;
|
|
272
306
|
/** Omni-side policy: whether the agent's tasks are accepted without asking them. */
|
|
273
307
|
autoAcceptTasks?: boolean;
|
|
274
|
-
/**
|
|
275
|
-
|
|
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;
|
|
276
313
|
signal?: AbortSignal;
|
|
277
314
|
log?: (entry: unknown) => void;
|
|
278
315
|
}
|
|
@@ -811,7 +848,7 @@ export interface VoiceMediaSession {
|
|
|
811
848
|
}
|
|
812
849
|
export interface OpenMediaRequest {
|
|
813
850
|
taskId: TaskId;
|
|
814
|
-
/** The agent's microphone as Omni captured it
|
|
851
|
+
/** The agent's microphone as Omni captured it, `HostReport.audio.input.localAudio`; absent while that input is `unavailable`. */
|
|
815
852
|
localAudio?: MediaStream;
|
|
816
853
|
}
|
|
817
854
|
export type OpenMediaResult = {
|
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
|
/**
|
|
@@ -103,12 +103,28 @@ export declare class TaskStream {
|
|
|
103
103
|
/** Applies one envelope and returns what it may not say given what came before. */
|
|
104
104
|
apply(envelope: unknown, path?: string): ProtocolViolation[];
|
|
105
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;
|
|
106
120
|
/**
|
|
107
121
|
* The media follows the task and never decides it. Given a provider's stream -- optionally seeded
|
|
108
122
|
* with the snapshot it began from -- every task is introduced once, `task-media-ended` names a
|
|
109
123
|
* task whose work has begun, and what follows it is `completing` or `task-ended`.
|
|
110
124
|
*/
|
|
111
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;
|
|
112
128
|
/** One provider as the host sees it when freezing a break attempt's participant set. */
|
|
113
129
|
export interface BreakCandidate {
|
|
114
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,
|
|
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
|
|
@@ -143,6 +143,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
143
143
|
const events = [];
|
|
144
144
|
const seen = new Set();
|
|
145
145
|
const stream = new TaskStream();
|
|
146
|
+
const breaks = new BreakStream();
|
|
146
147
|
let seeded = false;
|
|
147
148
|
const storedSecrets = new Map();
|
|
148
149
|
const authentication = await adapter.createAuthenticationSession({
|
|
@@ -156,7 +157,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
156
157
|
let connection;
|
|
157
158
|
let unsubscribe;
|
|
158
159
|
let unsubscribeAuthentication;
|
|
159
|
-
let
|
|
160
|
+
let unsubscribeHost;
|
|
160
161
|
let authenticationState;
|
|
161
162
|
let login;
|
|
162
163
|
let disconnectWasClean = false;
|
|
@@ -227,15 +228,28 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
227
228
|
requireCapabilityMethods(connection, state.capabilities);
|
|
228
229
|
}
|
|
229
230
|
});
|
|
230
|
-
// The host's
|
|
231
|
-
// testing a host that cannot exist. Its first
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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` });
|
|
237
243
|
}
|
|
238
|
-
|
|
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 });
|
|
239
253
|
const live = connection;
|
|
240
254
|
// Dial is declared by presence: the capability object carries a destination policy rather
|
|
241
255
|
// than an `enabled` flag, so its presence is the declaration.
|
|
@@ -252,7 +266,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
252
266
|
requireMethod(live, "describeUsers", "an event publishes a UserId");
|
|
253
267
|
// Cross-event rules apply once the stream has a beginning: the connect snapshot.
|
|
254
268
|
if (seeded)
|
|
255
|
-
violations.push(...stream.apply(envelope));
|
|
269
|
+
violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
|
|
256
270
|
if (typeof envelope?.id === "string") {
|
|
257
271
|
if (eventIds.has(envelope.id))
|
|
258
272
|
return;
|
|
@@ -264,12 +278,22 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
264
278
|
observeSnapshot(snapshot, seen);
|
|
265
279
|
violations.push(...validateSnapshot(snapshot, adapter.manifest, "snapshot", reader()));
|
|
266
280
|
stream.seed(snapshot);
|
|
281
|
+
breaks.seed(snapshot);
|
|
267
282
|
seeded = true;
|
|
268
283
|
requireCapabilityMethods(live, current().capabilities);
|
|
269
284
|
if (publishesUserIds(snapshot))
|
|
270
285
|
requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
|
|
271
286
|
// Capacity is stated, not requested: nothing may be allocated until it is, so a connection
|
|
272
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
|
+
}
|
|
273
297
|
const capacity = await connection.setCapacity({ count: 1 });
|
|
274
298
|
const malformed = validateResult(capacity, "setCapacity", "connection.setCapacity");
|
|
275
299
|
violations.push(...malformed);
|
|
@@ -297,7 +321,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
297
321
|
clean = false;
|
|
298
322
|
}
|
|
299
323
|
try {
|
|
300
|
-
|
|
324
|
+
unsubscribeHost?.();
|
|
301
325
|
}
|
|
302
326
|
catch {
|
|
303
327
|
clean = false;
|
|
@@ -622,6 +646,69 @@ export class TaskStream {
|
|
|
622
646
|
return found;
|
|
623
647
|
}
|
|
624
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
|
+
}
|
|
625
712
|
/**
|
|
626
713
|
* The media follows the task and never decides it. Given a provider's stream -- optionally seeded
|
|
627
714
|
* with the snapshot it began from -- every task is introduced once, `task-media-ended` names a
|
|
@@ -635,6 +722,10 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
|
|
|
635
722
|
envelopes.forEach((envelope, index) => found.push(...stream.apply(envelope, `envelopes[${index}]`)));
|
|
636
723
|
assertNoViolations(found, "The media follows the task");
|
|
637
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
|
+
}
|
|
638
729
|
const usableLogin = (status) => status === "authenticated" || status === "refreshing";
|
|
639
730
|
/**
|
|
640
731
|
* The participant set of a break attempt is every connected provider from which the agent can
|
package/dist/validation.d.ts
CHANGED
|
@@ -39,11 +39,11 @@ export declare function validateTeamRoster(roster: unknown, path?: string, conte
|
|
|
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
41
|
/**
|
|
42
|
-
* Validates the host's
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
45
|
*/
|
|
46
|
-
export declare function
|
|
46
|
+
export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
|
|
47
47
|
/** The connection methods whose results `validateResult` knows. */
|
|
48
48
|
export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia";
|
|
49
49
|
/**
|
package/dist/validation.js
CHANGED
|
@@ -1144,32 +1144,69 @@ 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
|
+
}
|
|
1147
1157
|
/**
|
|
1148
|
-
* Validates the host's
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
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.
|
|
1151
1161
|
*/
|
|
1152
|
-
export function
|
|
1162
|
+
export function validateHostReport(report, path = "host") {
|
|
1153
1163
|
const into = new Collector();
|
|
1154
|
-
if (!isPlainObject(
|
|
1155
|
-
into.add("
|
|
1164
|
+
if (!isPlainObject(report)) {
|
|
1165
|
+
into.add("host.shape", path, "a host report must be an object");
|
|
1156
1166
|
return into.violations;
|
|
1157
1167
|
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
into.
|
|
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;
|
|
1161
1174
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
}
|
|
1169
|
-
into.require(
|
|
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);
|
|
1170
1207
|
}
|
|
1171
1208
|
else {
|
|
1172
|
-
into.add("
|
|
1209
|
+
into.add("host.audio.output.status", `${out}.status`, `an output is ready or unavailable, not ${String(output.status)}`);
|
|
1173
1210
|
}
|
|
1174
1211
|
return into.violations;
|
|
1175
1212
|
}
|
package/guide.md
CHANGED
|
@@ -245,25 +245,84 @@ type AuthenticationFailure = {
|
|
|
245
245
|
field?: string;
|
|
246
246
|
};
|
|
247
247
|
|
|
248
|
-
type
|
|
249
|
-
|
|
250
|
-
|
|
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
|
+
};
|
|
251
266
|
|
|
252
|
-
type
|
|
253
|
-
|
|
254
|
-
subscribe(listener: (
|
|
267
|
+
type Host = {
|
|
268
|
+
report(): HostReport;
|
|
269
|
+
subscribe(listener: (report: HostReport) => void): Unsubscribe;
|
|
255
270
|
};
|
|
256
271
|
|
|
257
272
|
type ConnectContext = {
|
|
258
273
|
protocolVersion: number;
|
|
259
274
|
sessionId: string;
|
|
260
275
|
autoAcceptTasks?: boolean;
|
|
261
|
-
|
|
276
|
+
host: Host;
|
|
262
277
|
signal?: AbortSignal;
|
|
263
278
|
log?: (entry: unknown) => void;
|
|
264
279
|
};
|
|
265
280
|
|
|
266
281
|
type ConnectionStatus = "connecting" | "active" | "error";
|
|
282
|
+
|
|
283
|
+
type CredentialField = {
|
|
284
|
+
name: string;
|
|
285
|
+
label: string;
|
|
286
|
+
type: "text" | "password";
|
|
287
|
+
required?: boolean;
|
|
288
|
+
autocomplete?: string;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
type AuthenticationChallenge =
|
|
292
|
+
| { flowId: string; method: "browser-sso"; authorizationUrl: string; browser: "system" | "omni" }
|
|
293
|
+
| { flowId: string; method: "credentials"; fields: CredentialField[] };
|
|
294
|
+
|
|
295
|
+
type StartAuthenticationRequest =
|
|
296
|
+
| { requestId: string; method: "browser-sso"; callbackUrl: string }
|
|
297
|
+
| { requestId: string; method: "credentials" };
|
|
298
|
+
|
|
299
|
+
type StartAuthenticationResult =
|
|
300
|
+
| { status: "interaction-required"; challenge: AuthenticationChallenge }
|
|
301
|
+
| { status: "rejected"; failure: AuthenticationFailure };
|
|
302
|
+
|
|
303
|
+
type CompleteAuthenticationRequest =
|
|
304
|
+
| { flowId: string; method: "browser-sso"; callbackUrl: string }
|
|
305
|
+
| { flowId: string; method: "credentials"; values: Readonly<Record<string, string>> };
|
|
306
|
+
|
|
307
|
+
type CompleteAuthenticationResult =
|
|
308
|
+
| { status: "authenticated"; identity: User; capabilities: SessionCapabilities; expiresAt?: IsoTimestamp }
|
|
309
|
+
| { status: "rejected"; failure: AuthenticationFailure };
|
|
310
|
+
|
|
311
|
+
type AuthenticationActionResult =
|
|
312
|
+
| { status: "accepted" }
|
|
313
|
+
| { status: "failed"; failure: AuthenticationFailure };
|
|
314
|
+
|
|
315
|
+
type Unsubscribe = () => void;
|
|
316
|
+
|
|
317
|
+
type AuthenticationSession = {
|
|
318
|
+
state(): AuthenticationState | Promise<AuthenticationState>;
|
|
319
|
+
subscribe(listener: (state: AuthenticationState) => void): Unsubscribe;
|
|
320
|
+
start(request: StartAuthenticationRequest): Promise<StartAuthenticationResult>;
|
|
321
|
+
complete(request: CompleteAuthenticationRequest): Promise<CompleteAuthenticationResult>;
|
|
322
|
+
cancelAuthentication(flowId: string): Promise<AuthenticationActionResult>;
|
|
323
|
+
signOut(): Promise<AuthenticationActionResult>;
|
|
324
|
+
close(): Promise<void>;
|
|
325
|
+
};
|
|
267
326
|
```
|
|
268
327
|
|
|
269
328
|
### Provider state
|
|
@@ -282,6 +341,10 @@ type Snapshot = {
|
|
|
282
341
|
type AgentCapacity = {
|
|
283
342
|
count: number; // absolute ceiling, at least 1
|
|
284
343
|
};
|
|
344
|
+
|
|
345
|
+
type CapacityResult =
|
|
346
|
+
| { status: "accepted" }
|
|
347
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
285
348
|
```
|
|
286
349
|
|
|
287
350
|
### Idle contributions
|
|
@@ -302,6 +365,14 @@ type ScheduledActivity = {
|
|
|
302
365
|
contact?: Contact;
|
|
303
366
|
attributes?: Attribute[];
|
|
304
367
|
};
|
|
368
|
+
|
|
369
|
+
type DialRequest = {
|
|
370
|
+
destination: string;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
type DialResult =
|
|
374
|
+
| { status: "dialled" }
|
|
375
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
305
376
|
```
|
|
306
377
|
|
|
307
378
|
### Task capabilities
|
|
@@ -378,15 +449,24 @@ const BROWSER_ISOLATION_SCHEMES = {
|
|
|
378
449
|
type BrowserIsolationScheme =
|
|
379
450
|
(typeof BROWSER_ISOLATION_SCHEMES)[keyof typeof BROWSER_ISOLATION_SCHEMES];
|
|
380
451
|
|
|
381
|
-
type
|
|
452
|
+
type TaskBrowserBase = {
|
|
382
453
|
id: string;
|
|
383
454
|
name: string;
|
|
384
455
|
purpose: string;
|
|
385
456
|
url: string;
|
|
386
|
-
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
type TaskBrowser = TaskBrowserBase & (
|
|
387
460
|
| { reuse: false; isolationScheme?: never }
|
|
388
461
|
| { reuse: true; isolationScheme: BrowserIsolationScheme }
|
|
389
462
|
);
|
|
463
|
+
|
|
464
|
+
type BrowserSessionKeyInput = {
|
|
465
|
+
providerId: string;
|
|
466
|
+
taskId: TaskId;
|
|
467
|
+
taskType: string;
|
|
468
|
+
browser: TaskBrowser;
|
|
469
|
+
};
|
|
390
470
|
```
|
|
391
471
|
|
|
392
472
|
That union is what makes a reusing browser with no scheme fail to compile rather than inherit a
|
|
@@ -559,6 +639,10 @@ type TaskCommandRequest<C extends Channel = Channel> = {
|
|
|
559
639
|
taskId: TaskId;
|
|
560
640
|
command: TaskCommand<C>;
|
|
561
641
|
};
|
|
642
|
+
|
|
643
|
+
type TaskCommandResult =
|
|
644
|
+
| { status: "applied" }
|
|
645
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
562
646
|
```
|
|
563
647
|
|
|
564
648
|
### Breaks
|
|
@@ -598,6 +682,22 @@ type BreakState = {
|
|
|
598
682
|
activeReasonId?: string;
|
|
599
683
|
imposed?: ImposedBreak;
|
|
600
684
|
};
|
|
685
|
+
|
|
686
|
+
type BreakRequestResult =
|
|
687
|
+
| { status: "requested" }
|
|
688
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
689
|
+
|
|
690
|
+
type BreakCommitResult =
|
|
691
|
+
| { status: "committed" }
|
|
692
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
693
|
+
|
|
694
|
+
type BreakCancelResult =
|
|
695
|
+
| { status: "cancelled" }
|
|
696
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
697
|
+
|
|
698
|
+
type BreakEndResult =
|
|
699
|
+
| { status: "ended" }
|
|
700
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
601
701
|
```
|
|
602
702
|
|
|
603
703
|
### Team
|
|
@@ -634,6 +734,18 @@ type TeamBreakCommand =
|
|
|
634
734
|
| { type: "policy"; policy: "ask" | "auto-approve" | "suspended" }
|
|
635
735
|
| { type: "place"; memberId: UserId; reason?: string }
|
|
636
736
|
| { type: "release"; memberId: UserId };
|
|
737
|
+
|
|
738
|
+
type TeamBreakCommandRequest = {
|
|
739
|
+
command: TeamBreakCommand;
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
type TeamConsultCommandRequest = {
|
|
743
|
+
command: TeamConsultCommand;
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
type TeamCommandResult =
|
|
747
|
+
| { status: "applied" }
|
|
748
|
+
| { status: "failed"; failure: ProtocolFailure };
|
|
637
749
|
```
|
|
638
750
|
|
|
639
751
|
### Media
|
|
@@ -648,6 +760,11 @@ type VoiceMediaSession = {
|
|
|
648
760
|
type OpenMediaResult =
|
|
649
761
|
| { status: "opened"; session: VoiceMediaSession }
|
|
650
762
|
| { status: "unavailable"; failure: ProtocolFailure };
|
|
763
|
+
|
|
764
|
+
type OpenMediaRequest = {
|
|
765
|
+
taskId: TaskId;
|
|
766
|
+
localAudio?: MediaStream;
|
|
767
|
+
};
|
|
651
768
|
```
|
|
652
769
|
|
|
653
770
|
### Events
|
|
@@ -695,12 +812,43 @@ type ProviderEventEnvelope = {
|
|
|
695
812
|
reason rather than a naming one: `Event` is a DOM global, and a bare one would shadow it for every
|
|
696
813
|
adapter compiled against the browser lib.
|
|
697
814
|
|
|
815
|
+
### Adapter and connection
|
|
816
|
+
|
|
817
|
+
```ts
|
|
818
|
+
type Connection<C extends Channel = Channel> = {
|
|
819
|
+
snapshot(): Snapshot<C> | Promise<Snapshot<C>>;
|
|
820
|
+
subscribe(listener: (envelope: ProviderEventEnvelope<C>) => void): Unsubscribe;
|
|
821
|
+
setCapacity(capacity: AgentCapacity): Promise<CapacityResult>;
|
|
822
|
+
execute(request: TaskCommandRequest<C>): Promise<TaskCommandResult>;
|
|
823
|
+
disconnect(): Promise<void>;
|
|
824
|
+
|
|
825
|
+
describeUsers?(ids: UserId[]): Promise<User[]>;
|
|
826
|
+
dial?(request: DialRequest): Promise<DialResult>;
|
|
827
|
+
|
|
828
|
+
requestBreak?(request: BreakRequest): Promise<BreakRequestResult>;
|
|
829
|
+
commitBreak?(): Promise<BreakCommitResult>;
|
|
830
|
+
cancelBreak?(): Promise<BreakCancelResult>;
|
|
831
|
+
endBreak?(): Promise<BreakEndResult>;
|
|
832
|
+
|
|
833
|
+
executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
|
|
834
|
+
executeTeamConsult?(request: TeamConsultCommandRequest): Promise<TeamCommandResult>;
|
|
835
|
+
openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
type Adapter<C extends Channel = Channel> = {
|
|
839
|
+
manifest: Manifest<C>;
|
|
840
|
+
createAuthenticationSession(context: AuthenticationContext): Promise<AuthenticationSession> | AuthenticationSession;
|
|
841
|
+
connect(context: ConnectContext): Promise<Connection<C>>;
|
|
842
|
+
};
|
|
843
|
+
```
|
|
844
|
+
|
|
698
845
|
### Published constants
|
|
699
846
|
|
|
700
847
|
```ts
|
|
701
848
|
const ALLOWED_BROWSER_URL_SCHEMES = ["http:", "https:"] as const;
|
|
702
849
|
|
|
703
850
|
const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"] as const;
|
|
851
|
+
type IdleCapability = (typeof IDLE_CAPABILITIES)[number];
|
|
704
852
|
|
|
705
853
|
const IDLE_CAPABILITY_UI = {
|
|
706
854
|
dial: "Dialpad",
|
|
@@ -743,6 +891,7 @@ const OMNI_FAILURE_CODES = [
|
|
|
743
891
|
"omni.unavailable",
|
|
744
892
|
"omni.break-already-committed",
|
|
745
893
|
] as const;
|
|
894
|
+
type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
|
|
746
895
|
```
|
|
747
896
|
|
|
748
897
|
`HANDLING_STEPS_WITH_A_PERSON` is every `HandlingStep` except `queued`, which is the one nobody
|
|
@@ -1501,7 +1650,7 @@ Creates one live provider connection for the signed-in agent.
|
|
|
1501
1650
|
| `protocolVersion` | Version negotiated before authentication. Fixed for this login. |
|
|
1502
1651
|
| `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. |
|
|
1503
1652
|
| `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. |
|
|
1504
|
-
| `
|
|
1653
|
+
| `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**. |
|
|
1505
1654
|
| `signal` | Optional cancellation signal. Stop startup promptly when aborted and do not begin new work. |
|
|
1506
1655
|
| `log` | Optional structured logging callback. Never include credentials, tokens, or sensitive contact data. |
|
|
1507
1656
|
|
|
@@ -1682,6 +1831,7 @@ automatic or requires the agent. A provider that requires automatic acceptance s
|
|
|
1682
1831
|
declare const task: Task;
|
|
1683
1832
|
|
|
1684
1833
|
const allocation = {
|
|
1834
|
+
type: "task-offered",
|
|
1685
1835
|
task,
|
|
1686
1836
|
acceptanceMode: "require-agent-acceptance",
|
|
1687
1837
|
allocationExpiresAt: "2026-08-25T10:41:07.000Z",
|
|
@@ -2831,25 +2981,36 @@ phase follow the provider's reports about the work, and the media — attaching,
|
|
|
2831
2981
|
hold, a consult, a conference or a transfer, and ending — is transient beside it. See **A task is
|
|
2832
2982
|
never its audio** under **Task allocation lifecycle**.
|
|
2833
2983
|
|
|
2834
|
-
###
|
|
2984
|
+
### The host reports, the adapter decides
|
|
2835
2985
|
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2986
|
+
The adapter runs inside Omni and sees nothing of the station for itself: the devices, the
|
|
2987
|
+
permissions, the audio element and the network are the host's, and only the host can tell. So
|
|
2988
|
+
the host reports, and the adapter asks. **An adapter consults `ConnectContext.host` before it
|
|
2989
|
+
declares the agent ready to its platform, and again whenever the report changes**, and it decides
|
|
2990
|
+
what any of it means and what to relay upstream — go not-ready, refuse calls, carry on because the
|
|
2991
|
+
platform's audio lands elsewhere. Omni facilitates and does not take responsibility: it captures
|
|
2992
|
+
the microphone once as the voice connection opens, so the permission prompt lands while the agent
|
|
2993
|
+
is signing in rather than over a contact, prompts, retries on the agent's request, tells the agent
|
|
2994
|
+
what failed, and reports. It never decides for the adapter what a missing microphone means.
|
|
2842
2995
|
|
|
2843
|
-
|
|
|
2996
|
+
| Field | Contract |
|
|
2844
2997
|
| --- | --- |
|
|
2845
|
-
| `
|
|
2846
|
-
| `
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2998
|
+
| `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. |
|
|
2999
|
+
| `audio` | Present on a voice connection, absent where there is no audio. |
|
|
3000
|
+
| `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. A host decides the reason from the devices before the error name: a browser can report a permission error on a machine with no microphone at all, and "grant permission" is the wrong instruction for an agent who needs to plug one in. `failure` carries the words Omni showed them. |
|
|
3001
|
+
| `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. |
|
|
3002
|
+
|
|
3003
|
+
Omni republishes the report whenever it changes — a permission granted late, a headset unplugged,
|
|
3004
|
+
a network gone — and it publishes a state, not a flicker: a change that resolves within moments is
|
|
3005
|
+
not reported, so an adapter may act on what it reads without debouncing it again. Nothing in the
|
|
3006
|
+
report is a task fact or a capacity fact; it is what the station can do, and the adapter's platform
|
|
3007
|
+
is the one that knows whether an agent without a microphone, or without a speaker, can work.
|
|
3008
|
+
|
|
3009
|
+
The host's own obligations here — asking at connect, never publishing `not-asked` when it does,
|
|
3010
|
+
publishing a state and not a flicker — are Omni's tests' to hold. `exerciseAdapter` holds the
|
|
3011
|
+
other side: it validates the shape of whatever host a test hands the adapter, requires `audio` on
|
|
3012
|
+
a voice connection and none elsewhere (`context.host.audio.required` / `.unexpected`), and
|
|
3013
|
+
refuses a voice adapter that never asked the host anything (`connection.host.consulted`).
|
|
2853
3014
|
|
|
2854
3015
|
### Capacity around setup
|
|
2855
3016
|
|
|
@@ -2860,7 +3021,7 @@ set up — the adapter's own registration incomplete, its credentials not yet re
|
|
|
2860
3021
|
Nothing closes that window, because nothing opens it: **Omni states no capacity until the
|
|
2861
3022
|
connection is established**, and **Work is pulled, never pushed** makes an allocation with none
|
|
2862
3023
|
stated a violation. Capacity does not wait on the microphone: whether an agent without host audio
|
|
2863
|
-
can take calls is the platform's question, answered by the adapter from
|
|
3024
|
+
can take calls is the platform's question, answered by the adapter from the host's report, not by
|
|
2864
3025
|
Omni withholding capacity for every platform alike.
|
|
2865
3026
|
|
|
2866
3027
|
Capacity follows **automatically** once the connection is established; the agent does not press
|
|
@@ -2869,7 +3030,7 @@ anything to become available.
|
|
|
2869
3030
|
| Situation | What Omni sends |
|
|
2870
3031
|
| --- | --- |
|
|
2871
3032
|
| Connecting | Nothing. No capacity has been stated, so nothing may be allocated. |
|
|
2872
|
-
| Connected and idle | `setCapacity({ count: n })`,
|
|
3033
|
+
| Connected and idle | `setCapacity({ count: n })`, with the host's report already available to consult. |
|
|
2873
3034
|
| A task starts or ends | Nothing. The provider counts its own against the ceiling. |
|
|
2874
3035
|
| The agent's provisioned capacity changes | `setCapacity({ count: n })` |
|
|
2875
3036
|
| Agent asks for a break | `requestBreak`. Capacity is unchanged and work continues. |
|
|
@@ -2895,8 +3056,9 @@ The adapter speaks whatever its platform speaks — SIP over WebSocket, a vendor
|
|
|
2895
3056
|
WebRTC — and **none of that appears in this contract**. Registration, signalling, credential
|
|
2896
3057
|
renewal and reconnect are the adapter's, exactly as its authentication and transport already
|
|
2897
3058
|
are. Omni owns what belongs to the host: the microphone, the output element, mute, and when a
|
|
2898
|
-
session ends — owning the microphone meaning capturing it, prompting, retrying and
|
|
2899
|
-
stands, never deciding for the adapter what a missing one means (see **
|
|
3059
|
+
session ends — owning the microphone meaning capturing it, prompting, retrying and reporting how
|
|
3060
|
+
it stands, never deciding for the adapter what a missing one means (see **The host reports, the
|
|
3061
|
+
adapter decides**).
|
|
2900
3062
|
|
|
2901
3063
|
| Member | Contract |
|
|
2902
3064
|
| --- | --- |
|
|
@@ -2904,10 +3066,10 @@ stands, never deciding for the adapter what a missing one means (see **Host medi
|
|
|
2904
3066
|
| `setMuted(muted)` | Mutes the agent's microphone on this session. |
|
|
2905
3067
|
| `close()` | Releases the session. Omni calls it when the task ends. |
|
|
2906
3068
|
|
|
2907
|
-
`localAudio` is the agent's microphone as Omni captured it, the same stream
|
|
2908
|
-
and absent while that
|
|
2909
|
-
input may ignore it; one that needs it and finds it absent
|
|
2910
|
-
Omni shows the agent.
|
|
3069
|
+
`localAudio` is the agent's microphone as Omni captured it, the same stream the host's report
|
|
3070
|
+
carries as `audio.input.localAudio`, and absent while that input is `unavailable`. A provider that
|
|
3071
|
+
bridges audio without a host-side input may ignore it; one that needs it and finds it absent
|
|
3072
|
+
answers `unavailable` with a failure Omni shows the agent.
|
|
2911
3073
|
|
|
2912
3074
|
**A task-scoped session does not oblige one call per task.** A platform holding a nailed-up
|
|
2913
3075
|
leg for a whole shift may return the same session for every task and release the underlying
|
|
@@ -3120,6 +3282,15 @@ Replaces this provider's complete `break` object. Its `approval` uses the canoni
|
|
|
3120
3282
|
`in-effect` states defined under Breaks; the event also carries the corresponding accepting state,
|
|
3121
3283
|
reasons, retry details, and any imposed break.
|
|
3122
3284
|
|
|
3285
|
+
Each state is also held to the one before it. A commit's states, `starting-after-task` and
|
|
3286
|
+
`in-effect`, follow a grant — the one arrival in a committed state nobody asked for is a placed
|
|
3287
|
+
break, which says so with `imposed`: in effect at once, or `starting-after-task` while the member
|
|
3288
|
+
finishes the call they are on (`stream.breakState.commitBeforeGrant`); and a break never moves backwards —
|
|
3289
|
+
from `in-effect` or `starting-after-task` to a grant or a request, or from `granted` to
|
|
3290
|
+
`awaiting-decision` — a new request passes through `not-requested` (`stream.breakState.backwards`).
|
|
3291
|
+
`exerciseAdapter` holds the stream to that from the connect snapshot on;
|
|
3292
|
+
`assertBreakFollowsItsRequests` holds any sequence.
|
|
3293
|
+
|
|
3123
3294
|
For a multi-provider attempt, "every provider" is the participant set frozen when the attempt
|
|
3124
3295
|
entered `requesting-break`. Omni commits only after every participant reports `granted` —
|
|
3125
3296
|
that one is unconditional, because nothing has stopped yet and waiting costs only time. It enters
|
|
@@ -3237,7 +3408,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
3237
3408
|
| `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
|
|
3238
3409
|
| `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
|
|
3239
3410
|
| `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
|
|
3240
|
-
| `
|
|
3411
|
+
| `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. |
|
|
3241
3412
|
| `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. |
|
|
3242
3413
|
| `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. |
|
|
3243
3414
|
|
|
@@ -3324,7 +3495,10 @@ cannot be established from TypeScript structure alone.
|
|
|
3324
3495
|
| `assertReached(result, subjects)` | The exercise met every subject named; throws listing those it did not. Pair it with a clean `exerciseAdapter` result. |
|
|
3325
3496
|
| `assertAuthenticationRestoreAndExpiry(states)` | A restored authenticated session can refresh and ends in expiry. Every state is validated. |
|
|
3326
3497
|
| `assertReconnectWithMissedAssignments(before, reconnect, ids)` | A reconnect snapshot restores assignments received while offline. |
|
|
3327
|
-
| `
|
|
3498
|
+
| `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. |
|
|
3499
|
+
| `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. |
|
|
3500
|
+
| `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. |
|
|
3501
|
+
| `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. |
|
|
3328
3502
|
| `assertBreakParticipants(candidates, participants)` | A break attempt asks every usable provider holding capacity, `refreshing` included, and nothing of a provider whose login is `expired`. |
|
|
3329
3503
|
| `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. |
|
|
3330
3504
|
| `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
|