@inetafrica/open-claudia 3.4.13 → 3.4.15
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/CHANGELOG.md +14 -0
- package/bin/cli.js +28 -0
- package/bin/loopback-client.js +1 -0
- package/channels/kazee/call/manager.js +66 -14
- package/channels/kazee/call/session.js +171 -84
- package/channels/kazee/call/speech-client.js +96 -19
- package/channels/kazee/call/turn-gate.js +62 -0
- package/core/call-speech.js +111 -0
- package/core/loopback.js +10 -1
- package/core/providers/env.js +1 -0
- package/core/run-context.js +13 -6
- package/core/runner.js +58 -6
- package/core/system-prompt.js +9 -1
- package/package.json +4 -3
- package/test-call-speech.js +237 -0
- package/test-kazee-call-manager.js +57 -2
- package/test-kazee-call-session.js +217 -9
- package/test-kazee-call-speech.js +38 -2
- package/test-provider-env-isolation.js +5 -0
- package/test-provider-gateway.js +15 -1
- package/test-provider-language.js +3 -1
- package/test-provider-prompt-parity.js +16 -1
- package/test-provider-runner.js +34 -0
- package/test-tool-manifest.js +25 -12
- package/test-tools.js +11 -7
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const assert = require("assert");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const os = require("os");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const http = require("http");
|
|
9
|
+
const { spawn } = require("child_process");
|
|
10
|
+
|
|
11
|
+
function cli(env, args, input = "") {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const child = spawn(process.execPath, [path.join(__dirname, "bin/cli.js"), "call-say", ...args],
|
|
14
|
+
{ env, stdio: ["pipe", "pipe", "pipe"] });
|
|
15
|
+
let stdout = "", stderr = "";
|
|
16
|
+
child.stdout.on("data", data => { stdout += data; });
|
|
17
|
+
child.stderr.on("data", data => { stderr += data; });
|
|
18
|
+
child.on("error", reject);
|
|
19
|
+
child.on("close", code => resolve({ code, stdout, stderr }));
|
|
20
|
+
child.stdin.end(input);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Real provider child -> real CLI child -> HTTP -> call adapter, with no
|
|
25
|
+
// model, speech service, or real recipient. Emit a terminal result only AFTER
|
|
26
|
+
// the first sentence has been acknowledged as completely spoken.
|
|
27
|
+
if (process.env.OC_CALL_SPEECH_FIXTURE === "1") {
|
|
28
|
+
(async () => {
|
|
29
|
+
const hasPermission = !!process.env.OC_CALL_SPEECH_TOKEN;
|
|
30
|
+
if (hasPermission) {
|
|
31
|
+
const result = await cli(process.env, ["--id", "first", "The first result is ready."]);
|
|
32
|
+
assert.strictEqual(result.code, 0, result.stderr);
|
|
33
|
+
}
|
|
34
|
+
process.stdout.write(JSON.stringify({ type: "result", subtype: "success", is_error: false,
|
|
35
|
+
result: hasPermission ? "The first result is ready. The second result is ready." : "No call speech permission." }) + "\n");
|
|
36
|
+
})().catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
37
|
+
} else {
|
|
38
|
+
process.env.OPEN_CLAUDIA_TEST = "1";
|
|
39
|
+
process.env.OPEN_CLAUDIA_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "oc-call-speech-"));
|
|
40
|
+
process.env.WORKSPACE = process.env.OPEN_CLAUDIA_CONFIG_DIR;
|
|
41
|
+
const loopback = require("./core/loopback");
|
|
42
|
+
const speech = require("./core/call-speech");
|
|
43
|
+
const relationship = require("./core/relationship");
|
|
44
|
+
const enforcer = require("./core/enforcer");
|
|
45
|
+
const context = require("./core/context");
|
|
46
|
+
const { registerSecrets } = require("./core/redact");
|
|
47
|
+
const leases = [];
|
|
48
|
+
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; };
|
|
49
|
+
let sequence = 0;
|
|
50
|
+
function fixture() {
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const spoken = [];
|
|
53
|
+
let active = true, cancelled = 0;
|
|
54
|
+
const adapter = { id: "kazee-test", type: "kazee", liveCall: true,
|
|
55
|
+
callTurn: { signal: controller.signal },
|
|
56
|
+
isCallTurnActive: () => active,
|
|
57
|
+
speakSentence: async text => { spoken.push(text); return true; },
|
|
58
|
+
cancelCallSpeech: () => { cancelled++; },
|
|
59
|
+
};
|
|
60
|
+
const store = { adapter, channelId: "call-room", userId: "caller", canonicalUserId: "kazee:caller", transport: "kazee" };
|
|
61
|
+
const run = { liveCall: true, purpose: "foreground", runId: `run-${++sequence}`, timeoutMs: 5000 };
|
|
62
|
+
const lease = speech.prepare(store, run);
|
|
63
|
+
leases.push(lease);
|
|
64
|
+
const payload = { adapter: adapter.id, channelId: store.channelId, userId: store.userId,
|
|
65
|
+
canonicalUserId: store.canonicalUserId, originRunId: run.runId,
|
|
66
|
+
callSpeechToken: lease.token, requestId: "sentence", text: "The result is ready." };
|
|
67
|
+
return { adapter, store, run, lease, payload, spoken, controller,
|
|
68
|
+
mute() { active = false; }, get cancelled() { return cancelled; } };
|
|
69
|
+
}
|
|
70
|
+
function post(info, payload, token = info.token) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const data = JSON.stringify(payload);
|
|
73
|
+
const req = http.request(`${info.url}/call-say`, { method: "POST", headers: {
|
|
74
|
+
Authorization: `Bearer ${token}`, "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data),
|
|
75
|
+
} }, res => {
|
|
76
|
+
let body = "";
|
|
77
|
+
res.on("data", chunk => { body += chunk; });
|
|
78
|
+
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(body) }));
|
|
79
|
+
});
|
|
80
|
+
req.on("error", reject); req.end(data);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const rejectsStatus = (promise, status) => assert.rejects(promise, error => error.status === status);
|
|
84
|
+
async function main() {
|
|
85
|
+
relationship.isCurrentSpeakerGuarded = () => false;
|
|
86
|
+
const info = await loopback.start({ findAdapter: id => id === "kazee-test" ? { type: "kazee" } : null });
|
|
87
|
+
let f = fixture();
|
|
88
|
+
assert.strictEqual(speech.prepare(f.store, { ...f.run, liveCall: false }), null);
|
|
89
|
+
assert.strictEqual(speech.prepare(f.store, { ...f.run, purpose: "silent" }), null);
|
|
90
|
+
await rejectsStatus(speech.speak(f.payload), 409); // prepared but no provider yet
|
|
91
|
+
f.lease.activate(); f.lease.activate();
|
|
92
|
+
assert.strictEqual((await post(info, f.payload, "wrong-token")).status, 401);
|
|
93
|
+
for (const field of ["adapter", "channelId", "userId", "canonicalUserId", "originRunId", "callSpeechToken"]) {
|
|
94
|
+
await rejectsStatus(speech.speak({ ...f.payload, [field]: "other" }), 409);
|
|
95
|
+
}
|
|
96
|
+
assert.strictEqual(f.spoken.length, 0);
|
|
97
|
+
const env = { PATH: process.env.PATH, HOME: process.env.OPEN_CLAUDIA_CONFIG_DIR,
|
|
98
|
+
OPEN_CLAUDIA_CONFIG_DIR: process.env.OPEN_CLAUDIA_CONFIG_DIR,
|
|
99
|
+
OC_SEND_URL: info.url, OC_SEND_TOKEN: info.token, OC_CHANNEL_ADAPTER: f.adapter.id,
|
|
100
|
+
OC_CHANNEL_ID: f.store.channelId, OC_CHANNEL_USER_ID: f.store.userId,
|
|
101
|
+
OC_CANONICAL_USER_ID: f.store.canonicalUserId, OC_RUN_ID: f.run.runId, OC_CALL_SPEECH_TOKEN: f.lease.token };
|
|
102
|
+
const text = "A quoted `name` and $(text) are spoken literally.";
|
|
103
|
+
assert.strictEqual((await cli(env, ["--id", "stdin", "--stdin"], text)).code, 0);
|
|
104
|
+
assert.deepStrictEqual(f.spoken, [text]);
|
|
105
|
+
const duplicate = await cli(env, ["--id", "stdin", "--stdin"], text);
|
|
106
|
+
assert.strictEqual(duplicate.code, 0);
|
|
107
|
+
assert.match(duplicate.stdout, /Already spoken/);
|
|
108
|
+
assert.strictEqual(f.spoken.length, 1);
|
|
109
|
+
assert.strictEqual((await cli(env, ["--id", "stdin", "Different text."])).code, 1);
|
|
110
|
+
assert.strictEqual((await cli({ ...env, OC_CALL_SPEECH_TOKEN: "" }, ["A sentence."])).code, 1);
|
|
111
|
+
assert.strictEqual((await post(info, { ...f.payload, text: "x".repeat(451) })).status, 400);
|
|
112
|
+
registerSecrets(["synthetic-speech-secret"]);
|
|
113
|
+
await speech.speak({ ...f.payload, requestId: "redacted", text: "Value synthetic-speech-secret." });
|
|
114
|
+
assert.strictEqual(f.spoken.at(-1), "Value [REDACTED_SECRET].");
|
|
115
|
+
f.lease.close();
|
|
116
|
+
await rejectsStatus(speech.speak(f.payload), 409);
|
|
117
|
+
|
|
118
|
+
// Recipient classification and the actual outbound guard run inside the
|
|
119
|
+
// captured caller context, not the HTTP client's supplied identity.
|
|
120
|
+
f = fixture(); f.lease.activate();
|
|
121
|
+
relationship.isCurrentSpeakerGuarded = () => {
|
|
122
|
+
assert.strictEqual(context.currentUserId(), "caller"); return true;
|
|
123
|
+
};
|
|
124
|
+
enforcer.guardOutboundReply = async () => ({ allow: false });
|
|
125
|
+
await rejectsStatus(speech.speak(f.payload), 403);
|
|
126
|
+
enforcer.guardOutboundReply = async () => { throw new Error("guard unavailable"); };
|
|
127
|
+
await rejectsStatus(speech.speak({ ...f.payload, requestId: "guard-error" }), 403);
|
|
128
|
+
relationship.isCurrentSpeakerGuarded = () => { throw new Error("classification unavailable"); };
|
|
129
|
+
await rejectsStatus(speech.speak({ ...f.payload, requestId: "class-error" }), 403);
|
|
130
|
+
assert.strictEqual(f.spoken.length, 0);
|
|
131
|
+
relationship.isCurrentSpeakerGuarded = () => true;
|
|
132
|
+
enforcer.guardOutboundReply = async text => {
|
|
133
|
+
assert.strictEqual(context.currentCanonicalUserId(), "kazee:caller");
|
|
134
|
+
assert.strictEqual(text, f.payload.text);
|
|
135
|
+
return { allow: true };
|
|
136
|
+
};
|
|
137
|
+
assert.strictEqual((await speech.speak({ ...f.payload, requestId: "approved" })).spoken, true);
|
|
138
|
+
enforcer.guardOutboundReply = async text => {
|
|
139
|
+
assert.strictEqual(text, `${f.payload.text} Follow-up result.`);
|
|
140
|
+
return { allow: false };
|
|
141
|
+
};
|
|
142
|
+
await rejectsStatus(speech.speak({ ...f.payload, requestId: "cumulative", text: "Follow-up result." }), 403);
|
|
143
|
+
assert.strictEqual(f.spoken.length, 1, "guard sees the combined spoken answer, not an isolated follow-up");
|
|
144
|
+
|
|
145
|
+
f = fixture(); f.lease.activate();
|
|
146
|
+
relationship.isCurrentSpeakerGuarded = () => true;
|
|
147
|
+
const checking = deferred(), releaseCheck = deferred();
|
|
148
|
+
enforcer.guardOutboundReply = async () => { checking.resolve(); await releaseCheck.promise; return { allow: true }; };
|
|
149
|
+
const guarded = rejectsStatus(speech.speak(f.payload), 409);
|
|
150
|
+
await checking.promise; f.mute(); releaseCheck.resolve(); await guarded;
|
|
151
|
+
assert.strictEqual(f.spoken.length, 0, "speech resumed while reply guard was checking");
|
|
152
|
+
|
|
153
|
+
relationship.isCurrentSpeakerGuarded = () => false;
|
|
154
|
+
f = fixture(); f.lease.activate();
|
|
155
|
+
const playback = deferred(), playing = deferred();
|
|
156
|
+
f.adapter.speakSentence = async () => { playing.resolve(); return playback.promise; };
|
|
157
|
+
const first = speech.speak(f.payload);
|
|
158
|
+
const firstRejected = rejectsStatus(first, 409);
|
|
159
|
+
await playing.promise;
|
|
160
|
+
const retryRejected = rejectsStatus(speech.speak(f.payload), 409);
|
|
161
|
+
await rejectsStatus(speech.speak({ ...f.payload, requestId: "overlap" }), 429);
|
|
162
|
+
f.controller.abort(); playback.resolve(false);
|
|
163
|
+
await Promise.all([firstRejected, retryRejected]);
|
|
164
|
+
assert.strictEqual(f.cancelled, 1, "abort cancels current playback once");
|
|
165
|
+
await rejectsStatus(speech.speak(f.payload), 409);
|
|
166
|
+
|
|
167
|
+
f = fixture(); f.lease.activate();
|
|
168
|
+
for (let n = 0; n < 8; n++) await speech.speak({ ...f.payload, requestId: `bounded-${n}` });
|
|
169
|
+
await rejectsStatus(speech.speak({ ...f.payload, requestId: "ninth" }), 429);
|
|
170
|
+
assert.strictEqual(speech.unspokenSuffix("One. Two. Three.", ["One.", "Two."]), "Three.");
|
|
171
|
+
assert.strictEqual(speech.unspokenSuffix("One. Two.", ["One.", "Two."]), "");
|
|
172
|
+
assert.strictEqual(speech.unspokenSuffix("Correction: One is wrong.", ["One."]), "Correction: One is wrong.");
|
|
173
|
+
f = fixture(); f.lease.activate();
|
|
174
|
+
const expired = deferred(); f.adapter.speakSentence = () => expired.promise;
|
|
175
|
+
const expiring = rejectsStatus(speech.speak(f.payload), 409);
|
|
176
|
+
f.lease.close(); expired.resolve(false); await expiring;
|
|
177
|
+
assert.strictEqual(f.cancelled, 1);
|
|
178
|
+
|
|
179
|
+
await runnerIntegration(info);
|
|
180
|
+
console.log("call speech lease, guard, CLI and runner integration OK");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function runnerIntegration(info) {
|
|
184
|
+
// Replace only model/prompt/observer dependencies; use the production
|
|
185
|
+
// foreground factory, invocation decorator, subprocess lifecycle and HTTP.
|
|
186
|
+
const providers = require("./core/providers");
|
|
187
|
+
const { createClaudeProvider } = require("./core/providers/claude");
|
|
188
|
+
const provider = createClaudeProvider({ hookTransport: false,
|
|
189
|
+
resolveExecutable: () => process.execPath, defaultModel: "fixture-model",
|
|
190
|
+
getAuthStatus: () => ({ state: "authenticated", reason: null }),
|
|
191
|
+
buildEnv: () => ({ PATH: process.env.PATH, HOME: process.env.OPEN_CLAUDIA_CONFIG_DIR,
|
|
192
|
+
OPEN_CLAUDIA_CONFIG_DIR: process.env.OPEN_CLAUDIA_CONFIG_DIR, OC_CALL_SPEECH_FIXTURE: "1" }),
|
|
193
|
+
});
|
|
194
|
+
const build = provider.buildMainInvocation.bind(provider);
|
|
195
|
+
provider.buildMainInvocation = input => { const result = build(input); return { ...result, args: [__filename, ...result.args] }; };
|
|
196
|
+
providers.resolveProviderForRun = () => provider;
|
|
197
|
+
require("./core/system-prompt").buildTurnPrompt = async prompt => ({
|
|
198
|
+
coreInstructions: "Fixture", dynamicContext: "", userPrompt: prompt, transcriptPaths: {}, recallMetadata: {},
|
|
199
|
+
});
|
|
200
|
+
require("./core/turn-observer").createTurnObserver = () => ({ onRecallSurfaced() {}, onToolStart() {} });
|
|
201
|
+
delete require.cache[require.resolve("./core/runner")];
|
|
202
|
+
const { createForegroundService } = require("./core/runner");
|
|
203
|
+
let lastLease;
|
|
204
|
+
const prepare = speech.prepare;
|
|
205
|
+
speech.prepare = (...args) => { lastLease = prepare(...args); if (lastLease) leases.push(lastLease); return lastLease; };
|
|
206
|
+
const f = fixture();
|
|
207
|
+
const state = { userId: "kazee:caller", currentSession: { name: "call-fixture", dir: process.env.WORKSPACE },
|
|
208
|
+
settings: { backend: "claude", model: "fixture-model", permissionMode: "default" },
|
|
209
|
+
providerSettings: { claude: { model: "fixture-model" } }, activeSessions: {}, messageQueue: [] };
|
|
210
|
+
const spoken = [];
|
|
211
|
+
f.adapter.suppressStreamPreview = true; f.adapter.suppressChatVisibility = true;
|
|
212
|
+
f.adapter.speakSentence = async text => {
|
|
213
|
+
assert.ok(state.runningProcess, "sentence is spoken before provider completion");
|
|
214
|
+
spoken.push(text); return true;
|
|
215
|
+
};
|
|
216
|
+
f.adapter.send = async (_channel, text) => {
|
|
217
|
+
await rejectsStatus(speech.speak({ ...f.payload, callSpeechToken: lastLease?.token }), 409);
|
|
218
|
+
spoken.push(speech.unspokenSuffix(text, spoken)); return true;
|
|
219
|
+
};
|
|
220
|
+
f.adapter.typing = async () => {};
|
|
221
|
+
const service = createForegroundService({ state, store: f.store, stopTyping() {}, capture: false, ownsAdmissionLock: false });
|
|
222
|
+
const result = await context.runInChat(f.store, () => service.runAgent("Give two results.", process.env.WORKSPACE, null));
|
|
223
|
+
assert.strictEqual(result.ok, true, JSON.stringify(result.error));
|
|
224
|
+
assert.deepStrictEqual(spoken, ["The first result is ready.", "The second result is ready."]);
|
|
225
|
+
const token = lastLease.token;
|
|
226
|
+
assert.strictEqual((await post(info, { ...f.payload, callSpeechToken: token, originRunId: result.runId })).status, 409);
|
|
227
|
+
assert.strictEqual(state.runningProcess, null);
|
|
228
|
+
const capturedService = createForegroundService({ state, store: f.store, stopTyping() {}, capture: true, ownsAdmissionLock: false, persistCapture: false });
|
|
229
|
+
const captured = await context.runInChat(f.store, () => capturedService.runAgentCapture("Capture only.", process.env.WORKSPACE));
|
|
230
|
+
assert.strictEqual(captured.ok, true, JSON.stringify(captured.error));
|
|
231
|
+
assert.strictEqual(captured.text, "No call speech permission.");
|
|
232
|
+
speech.prepare = prepare;
|
|
233
|
+
}
|
|
234
|
+
main().then(() => { for (const lease of leases) lease.close(); loopback.stop(); process.exit(0); }, error => {
|
|
235
|
+
console.error(error); for (const lease of leases) lease.close(); loopback.stop(); process.exit(1);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
@@ -6,7 +6,7 @@ class FakeSession {
|
|
|
6
6
|
static instances = [];
|
|
7
7
|
constructor(options) { this.options = options; this.roomId = options.roomId; FakeSession.instances.push(this); }
|
|
8
8
|
async start() { this.started = true; }
|
|
9
|
-
async speak(text) { this.spoken = text; }
|
|
9
|
+
async speak(text) { this.spoken = text; (this.spokenChunks ||= []).push(text); }
|
|
10
10
|
async close(reason) { this.closed = reason; this.options.onEnded(reason); }
|
|
11
11
|
}
|
|
12
12
|
function fixture() {
|
|
@@ -37,8 +37,63 @@ function fixture() {
|
|
|
37
37
|
assert.strictEqual(f.inbound[0].payload.adapter.liveCall, true);
|
|
38
38
|
await f.inbound[0].payload.adapter.send("chat", "Your agenda is clear.");
|
|
39
39
|
assert.strictEqual(f.manager.session.spoken, "Your agenda is clear.");
|
|
40
|
-
|
|
40
|
+
const firstCallSession = f.manager.session;
|
|
41
|
+
const firstReply = f.inbound[0].payload.adapter;
|
|
42
|
+
firstCallSession.options.onSpeechStart();
|
|
43
|
+
await firstReply.send("chat", "An obsolete reply.");
|
|
44
|
+
assert.strictEqual(firstCallSession.spoken, "Your agenda is clear.", "resuming speech suppresses the old reply before a new final exists");
|
|
45
|
+
firstCallSession.options.onTranscript("Actually, what is tomorrow's agenda?");
|
|
46
|
+
assert.strictEqual(firstReply.callTurn.signal.aborted, true, "new complete utterance cancels obsolete queued work");
|
|
47
|
+
const secondReply = f.inbound[1].payload.adapter;
|
|
48
|
+
assert.ok(secondReply.callHistory.some(entry => entry.text === "What is on my agenda?"), "coalescing retains the earlier question as context");
|
|
49
|
+
assert.strictEqual(secondReply.callTurn.signal.aborted, false);
|
|
50
|
+
await secondReply.send("chat", "Tomorrow is free.");
|
|
51
|
+
assert.strictEqual(firstCallSession.spoken, "Tomorrow is free.");
|
|
52
|
+
let releaseClose;
|
|
53
|
+
const closeGate = new Promise(resolve => { releaseClose = resolve; });
|
|
54
|
+
firstCallSession.close = async reason => { await closeGate; firstCallSession.closed = reason; firstCallSession.options.onEnded(reason); };
|
|
55
|
+
const ending = f.manager.ended({ roomId: 12 });
|
|
56
|
+
assert.strictEqual(secondReply.callTurn.signal.aborted, true, "hangup invalidates queued work before slow media cleanup finishes");
|
|
57
|
+
releaseClose();
|
|
58
|
+
await ending;
|
|
41
59
|
assert.strictEqual(f.manager.session, null);
|
|
60
|
+
assert.strictEqual(secondReply.callTurn.signal.aborted, true, "hangup cancels queued work even after manager clears its session pointer");
|
|
61
|
+
await secondReply.send("chat", "Reply after hangup.");
|
|
62
|
+
assert.strictEqual(firstCallSession.spoken, "Tomorrow is free.");
|
|
63
|
+
await f.manager.incoming({ fromUserId: "owner-1", chatId: "chat", roomId: 13, callType: "audio" });
|
|
64
|
+
firstCallSession.options.onTranscript("Late transcript from old call.");
|
|
65
|
+
assert.strictEqual(f.inbound.length, 2, "old-session transcript cannot enter a replacement call");
|
|
66
|
+
for (let i = 0; i < 15; i++) f.manager.session.options.onTranscript(`${i} ${"a".repeat(2500)}`);
|
|
67
|
+
const history = f.inbound.at(-1).payload.adapter.callHistory;
|
|
68
|
+
assert.ok(history.length <= 8 && history.reduce((n, entry) => n + entry.text.length, 0) <= 12000, "voice history has an explicit bound");
|
|
69
|
+
await f.manager.stop();
|
|
70
|
+
|
|
71
|
+
// Explicitly streamed sentences enter history only when completely spoken;
|
|
72
|
+
// the settled final removes their exact prefix and preserves corrections.
|
|
73
|
+
f = fixture();
|
|
74
|
+
await f.manager.incoming({ fromUserId: "owner-1", chatId: "speech", roomId: 14, callType: "audio" });
|
|
75
|
+
const streamedSession = f.manager.session;
|
|
76
|
+
streamedSession.options.onTranscript("Give me both results.");
|
|
77
|
+
const streamed = f.inbound[0].payload.adapter;
|
|
78
|
+
assert.strictEqual(await streamed.speakSentence("First result."), true);
|
|
79
|
+
assert.strictEqual(await streamed.speakSentence("Second result."), true);
|
|
80
|
+
await streamed.send("speech", "First result. Second result. Third result.");
|
|
81
|
+
assert.deepStrictEqual(streamedSession.spokenChunks, ["First result.", "Second result.", "Third result."]);
|
|
82
|
+
await streamed.send("speech", "Correction: the first result changed.");
|
|
83
|
+
assert.strictEqual(streamedSession.spokenChunks.at(-1), "Correction: the first result changed.");
|
|
84
|
+
const beforeMute = streamedSession.spokenChunks.length;
|
|
85
|
+
streamedSession.options.onSpeechStart();
|
|
86
|
+
assert.strictEqual(await streamed.speakSentence("Stale sentence."), false);
|
|
87
|
+
assert.strictEqual(streamedSession.spokenChunks.length, beforeMute);
|
|
88
|
+
streamedSession.options.onTranscript("Now another question.");
|
|
89
|
+
const nextSpeech = f.inbound.at(-1).payload.adapter;
|
|
90
|
+
assert.ok(nextSpeech.callHistory.some(entry => entry.text.startsWith("First result. Second result.")));
|
|
91
|
+
assert.ok(nextSpeech.callHistory.some(entry => entry.text === "Give me both results."), "speech chunks keep the question in bounded history");
|
|
92
|
+
streamedSession.speak = async () => false;
|
|
93
|
+
assert.strictEqual(await nextSpeech.speakSentence("Interrupted sentence."), false);
|
|
94
|
+
assert.ok(!streamedSession._voiceHistory.some(entry => entry.text === "Interrupted sentence."));
|
|
95
|
+
await f.manager.stop();
|
|
96
|
+
assert.strictEqual(await nextSpeech.speakSentence("After hangup."), false);
|
|
42
97
|
|
|
43
98
|
// Outbound: session starts in caller mode, invite goes out, answer clears the ring timer.
|
|
44
99
|
f = fixture();
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
const assert = require("assert");
|
|
5
|
+
const { EventEmitter } = require("events");
|
|
5
6
|
const { KazeeCallSession } = require("./channels/kazee/call/session");
|
|
7
|
+
const { SpeechStream } = require("./channels/kazee/call/speech-client");
|
|
6
8
|
const { KazeeCallManager } = require("./channels/kazee/call/manager");
|
|
7
9
|
const { OpusAudioCodec, WEBRTC_SAMPLES } = require("./channels/kazee/call/audio");
|
|
8
10
|
|
|
@@ -63,6 +65,46 @@ function toneOpus(codec, amplitude) {
|
|
|
63
65
|
return codec.encode(pcm);
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
async function testDelayedSpeechProtocolReady() {
|
|
69
|
+
class DelayedWebSocket extends EventEmitter {
|
|
70
|
+
static OPEN = 1;
|
|
71
|
+
constructor() {
|
|
72
|
+
super(); this.readyState = 1; this.sent = [];
|
|
73
|
+
queueMicrotask(() => this.emit("open"));
|
|
74
|
+
}
|
|
75
|
+
send(value) { this.sent.push(value); }
|
|
76
|
+
close() { this.readyState = 3; this.emit("close"); }
|
|
77
|
+
}
|
|
78
|
+
const { session, transcripts } = makeSession();
|
|
79
|
+
await session._subscribe(901, "peer");
|
|
80
|
+
const sub = session.subscribers.get(901);
|
|
81
|
+
sub.stt.close();
|
|
82
|
+
sub.stt = new SpeechStream({
|
|
83
|
+
baseUrl: "https://speech.example", token: "fixture", WebSocketImpl: DelayedWebSocket,
|
|
84
|
+
onTranscript: (text, meta) => session._onFeedFinal(901, text, meta),
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
await sub.stt.connect();
|
|
88
|
+
session._commitTurn(901, "silence");
|
|
89
|
+
await wait(0);
|
|
90
|
+
const socket = sub.stt.socket;
|
|
91
|
+
// The caller finished before the gateway delivered its protocol greeting.
|
|
92
|
+
socket.emit("message", JSON.stringify({ type: "ready", protocol: 2, segment_id: 1 }));
|
|
93
|
+
socket.emit("message", JSON.stringify({ type: "final", segment_id: 1, text: "The complete question." }));
|
|
94
|
+
await Promise.resolve();
|
|
95
|
+
assert.deepStrictEqual(transcripts.map(t => t.text), ["The complete question."],
|
|
96
|
+
"late protocol negotiation must not orphan a committed final");
|
|
97
|
+
assert.strictEqual(sub.finalTimer, null, "a delivered final cancels the fallback");
|
|
98
|
+
session._commitTurn(901, "silence");
|
|
99
|
+
await wait(0);
|
|
100
|
+
socket.emit("message", JSON.stringify({ type: "final", segment_id: 2, text: "The next question." }));
|
|
101
|
+
await Promise.resolve();
|
|
102
|
+
assert.deepStrictEqual(transcripts.map(t => t.text), ["The complete question.", "The next question."]);
|
|
103
|
+
assert.deepStrictEqual(socket.sent.slice(1).map(JSON.parse), [{ type: "commit" }, { type: "commit" }],
|
|
104
|
+
"the connection keeps the same compatible commit format after its first turn");
|
|
105
|
+
} finally { await session.close(); }
|
|
106
|
+
}
|
|
107
|
+
|
|
66
108
|
async function testSpeakSerialization() {
|
|
67
109
|
const writes = [];
|
|
68
110
|
const synthCalls = [];
|
|
@@ -169,7 +211,7 @@ async function testHoldForWords() {
|
|
|
169
211
|
sub.stt.onPartial("hello there");
|
|
170
212
|
assert.strictEqual(sub.stt.commits, 1, "first words release the held commit");
|
|
171
213
|
assert.strictEqual(sub.awaitingWordsSince, 0);
|
|
172
|
-
assert(session._thinkTimer, "
|
|
214
|
+
assert.strictEqual(session._thinkTimer, null, "an unfinished transcript is not acknowledged");
|
|
173
215
|
gen.close();
|
|
174
216
|
await session.close();
|
|
175
217
|
}
|
|
@@ -242,6 +284,106 @@ async function testTrickleBuffering() {
|
|
|
242
284
|
await session.close();
|
|
243
285
|
}
|
|
244
286
|
|
|
287
|
+
async function testNoiseAfterFallbackCannotReplayPreviousUtterance() {
|
|
288
|
+
const { session, transcripts } = makeSession({ finalWaitMs: 30 });
|
|
289
|
+
await session._subscribe(101, "caller");
|
|
290
|
+
const sub = session.subscribers.get(101);
|
|
291
|
+
sub.stt.onPartial("I can hear you");
|
|
292
|
+
session._commitTurn(101, "silence");
|
|
293
|
+
await wait(60);
|
|
294
|
+
sub.noiseCommit = true;
|
|
295
|
+
session._commitTurn(101, "no-words");
|
|
296
|
+
await wait(60);
|
|
297
|
+
assert.deepStrictEqual(transcripts.map(t => t.text), ["I can hear you"], "a following noise turn cannot replay a consumed partial");
|
|
298
|
+
await session.close();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function testVersionedFinalsBelongToTheirCommittedSegment() {
|
|
302
|
+
const { session, transcripts } = makeSession({ finalWaitMs: 30 });
|
|
303
|
+
await session._subscribe(101, "caller");
|
|
304
|
+
const sub = session.subscribers.get(101);
|
|
305
|
+
Object.assign(sub.stt, { protocol: 2, streamId: 1, segmentId: 1 });
|
|
306
|
+
sub.stt.commitTurn = () => ({ streamId: 1, segmentId: sub.stt.segmentId++, pending: Promise.resolve() });
|
|
307
|
+
session._onFeedPartial(101, "only the start", { protocol: 2, streamId: 1, segmentId: 1 });
|
|
308
|
+
session._commitTurn(101, "silence");
|
|
309
|
+
await wait(60);
|
|
310
|
+
assert.strictEqual(transcripts.length, 0, "versioned service must complete the segment rather than dispatch an unfinished partial");
|
|
311
|
+
session._onFeedPartial(101, "another thought", { protocol: 2, streamId: 1, segmentId: 2 });
|
|
312
|
+
session._commitTurn(101, "silence");
|
|
313
|
+
session._onFeedFinal(101, "the whole first thought", { protocol: 2, streamId: 1, segmentId: 1 });
|
|
314
|
+
session._onFeedFinal(101, "duplicate", { protocol: 2, streamId: 1, segmentId: 1 });
|
|
315
|
+
session._onFeedFinal(101, "", { protocol: 2, streamId: 1, segmentId: 2 });
|
|
316
|
+
assert.deepStrictEqual(transcripts.map(t => t.text), ["the whole first thought"]);
|
|
317
|
+
assert.strictEqual(transcripts[0].meta.utteranceId, "1:1");
|
|
318
|
+
await session.close();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function testSemanticPauseResumesWithoutSplittingAudio() {
|
|
322
|
+
const { session } = makeSession();
|
|
323
|
+
const decisions = [];
|
|
324
|
+
session.turnDecision = (audio, { signal }) => new Promise(resolve => decisions.push({ resolve, signal, bytes: audio.length }));
|
|
325
|
+
await session._subscribe(101, "caller");
|
|
326
|
+
const sub = session.subscribers.get(101);
|
|
327
|
+
Object.assign(sub.stt, { protocol: 2, streamId: 1, segmentId: 1 });
|
|
328
|
+
sub.stt.commitTurn = () => {
|
|
329
|
+
sub.stt.commits++;
|
|
330
|
+
return { streamId: 1, segmentId: sub.stt.segmentId++, pending: Promise.resolve() };
|
|
331
|
+
};
|
|
332
|
+
const codec = new OpusAudioCodec();
|
|
333
|
+
const speech = () => { for (let i = 0; i < 3; i++) session._onFeedRtp(101, { payload: toneOpus(codec, 12000) }); };
|
|
334
|
+
const silence = () => { for (let i = 0; i < 36; i++) session._onFeedRtp(101, { payload: toneOpus(codec, 30) }); };
|
|
335
|
+
speech(); silence();
|
|
336
|
+
await wait(1);
|
|
337
|
+
assert.strictEqual(decisions.length, 1);
|
|
338
|
+
const captured = sub.stt.audioFrames.length;
|
|
339
|
+
speech();
|
|
340
|
+
assert(decisions[0].signal.aborted, "resumed speech cancels the old remote decision");
|
|
341
|
+
decisions[0].resolve({ complete: true });
|
|
342
|
+
await wait(1);
|
|
343
|
+
assert.strictEqual(sub.stt.commits, 0, "a late decision cannot split resumed speech");
|
|
344
|
+
assert(sub.stt.audioFrames.length > captured, "ingestion continues while the boundary is tentative");
|
|
345
|
+
silence();
|
|
346
|
+
await wait(1);
|
|
347
|
+
assert.strictEqual(decisions.length, 2);
|
|
348
|
+
assert(decisions[1].bytes > decisions[0].bytes, "the resumed thought retains its previous audio context");
|
|
349
|
+
decisions[1].resolve({ complete: true });
|
|
350
|
+
await wait(1);
|
|
351
|
+
assert.strictEqual(sub.stt.commits, 1);
|
|
352
|
+
assert.strictEqual(sub.stt.segmentId, 2);
|
|
353
|
+
codec.close();
|
|
354
|
+
await session.close();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function testTurnModelFailureAndCloseAreBounded() {
|
|
358
|
+
const { SpeechTurnGate } = require("./channels/kazee/call/turn-gate");
|
|
359
|
+
const commits = [];
|
|
360
|
+
const gate = new SpeechTurnGate({ decide: async () => { throw new Error("unavailable"); }, commit: reason => commits.push(reason), holdMs: 20 });
|
|
361
|
+
gate.audio(Buffer.alloc(640));
|
|
362
|
+
gate.candidate("silence");
|
|
363
|
+
await wait(40);
|
|
364
|
+
assert.deepStrictEqual(commits, ["silence-deadline"], "model failure falls back after a bounded pause");
|
|
365
|
+
gate.audio(Buffer.alloc(640));
|
|
366
|
+
gate.candidate("silence");
|
|
367
|
+
gate.close();
|
|
368
|
+
await wait(40);
|
|
369
|
+
assert.strictEqual(commits.length, 1, "closing prevents pending model/fallback work committing");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function testCancelStopsPendingTtsRequest() {
|
|
373
|
+
const { session } = makeSession();
|
|
374
|
+
let signal;
|
|
375
|
+
session.synthesizeStream = (_text, options) => new Promise((resolve, reject) => {
|
|
376
|
+
signal = options.signal;
|
|
377
|
+
signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
|
|
378
|
+
});
|
|
379
|
+
const speaking = session.speak("answer pending first audio");
|
|
380
|
+
assert(signal && !signal.aborted);
|
|
381
|
+
session.cancelSpeech();
|
|
382
|
+
assert(signal.aborted, "barge-in aborts the HTTP request before audio arrives");
|
|
383
|
+
assert.strictEqual(await speaking, false);
|
|
384
|
+
await session.close();
|
|
385
|
+
}
|
|
386
|
+
|
|
245
387
|
async function testTranscriptAttribution() {
|
|
246
388
|
const { session, transcripts } = makeSession({});
|
|
247
389
|
await session._subscribe(101, "alice@kazee");
|
|
@@ -391,16 +533,14 @@ async function testThinkingCues() {
|
|
|
391
533
|
const sub = session.subscribers.get(101);
|
|
392
534
|
const gen = new OpusAudioCodec();
|
|
393
535
|
|
|
394
|
-
//
|
|
395
|
-
// plays an audible "heard you" chime, and starts ticking right away — STT
|
|
396
|
-
// finalization takes seconds and must not sound like a dead line.
|
|
536
|
+
// A proposed boundary alone must not beep or claim to have heard a turn.
|
|
397
537
|
for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
|
|
398
538
|
sub.stt.onPartial("what is the status");
|
|
399
539
|
for (let i = 0; i < 36; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 30) });
|
|
400
540
|
assert.strictEqual(sub.stt.commits, 1);
|
|
401
541
|
await wait(60);
|
|
402
|
-
assert(frames
|
|
403
|
-
assert(session._thinkTimer, "
|
|
542
|
+
assert.strictEqual(frames, 0, "turn commit does not beep");
|
|
543
|
+
assert.strictEqual(session._thinkTimer, null, "no cue before recognized words are final");
|
|
404
544
|
|
|
405
545
|
// A noise commit finalizes to empty text: nothing dispatches, so the ticks
|
|
406
546
|
// stop rather than promising an answer that will never come.
|
|
@@ -421,7 +561,7 @@ async function testThinkingCues() {
|
|
|
421
561
|
assert.strictEqual(session._thinkTimer, null, "answer playback stops the thinking loop");
|
|
422
562
|
|
|
423
563
|
// A fresh thinking loop stops the moment the caller starts talking again.
|
|
424
|
-
session._startThinking();
|
|
564
|
+
session._startThinking("another question");
|
|
425
565
|
for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
|
|
426
566
|
assert.strictEqual(session._thinkTimer, null, "caller speech stops the thinking loop");
|
|
427
567
|
gen.close();
|
|
@@ -462,10 +602,11 @@ async function testManagedRoomCredentials() {
|
|
|
462
602
|
assert.strictEqual(session.janus.options.token, "root-token");
|
|
463
603
|
assert.strictEqual(session.janus.calls[0].token, "publisher-token");
|
|
464
604
|
await session._consumePublisherFrame({ plugindata: { data: { publishers: [{ id: 41, display: 'canonical-bot@calling.invalid' }, { id: 42, display: 'open-claudia:legacy-bot' }] } } });
|
|
465
|
-
assert.strictEqual(session.subscribers.size, 0, 'own joined publisher ID
|
|
605
|
+
assert.strictEqual(session.subscribers.size, 0, 'own joined publisher ID and unclassified managed feeds are excluded independently of display');
|
|
466
606
|
|
|
467
607
|
session.roomTokenExpiresAt = Date.now() - 1;
|
|
468
|
-
|
|
608
|
+
const userMetadata = { kazee: { version: 1, userId: 'a'.repeat(24), principalKind: 'User' } };
|
|
609
|
+
await Promise.all([session._subscribe(17, "owner", userMetadata), session._subscribe(18, "other", userMetadata)]);
|
|
469
610
|
const subJoin = session.janus.calls.find(body => body.ptype === "subscriber");
|
|
470
611
|
assert.strictEqual(subJoin.token, "subscriber-token");
|
|
471
612
|
assert.strictEqual(subJoin.private_id, 999);
|
|
@@ -484,6 +625,66 @@ async function testManagedRoomCredentials() {
|
|
|
484
625
|
assert.ok(!session.janus.calls.some(body => ["create", "destroy"].includes(body.request)), "server allocated room ownership never transfers to the bot");
|
|
485
626
|
}
|
|
486
627
|
|
|
628
|
+
async function testTrustedPublisherMetadata() {
|
|
629
|
+
const botId = 'b'.repeat(24), userId = 'a'.repeat(24);
|
|
630
|
+
const metadata = (principalKind, id = userId) => ({ kazee: { version: 1, userId: id, principalKind } });
|
|
631
|
+
const publisher = (id, meta, display = 'speaker@calling.invalid') => ({ id, display, metadata: meta });
|
|
632
|
+
let initial = [];
|
|
633
|
+
class MetadataJanus extends FakeJanus {
|
|
634
|
+
constructor() { super(); this.calls = []; this.listeners = new Map(); }
|
|
635
|
+
onHandle(handle, listener) { this.listeners.set(handle, listener); }
|
|
636
|
+
async message(handle, body) {
|
|
637
|
+
this.calls.push({ handle, ...body });
|
|
638
|
+
if (body.request === 'join' && body.ptype === 'publisher') return { plugindata: { data: { videoroom: 'joined', id: 41, private_id: 999, publishers: initial } } };
|
|
639
|
+
if (body.request === 'configure') return { jsep: { type: 'answer', sdp: 'fixture' } };
|
|
640
|
+
if (body.request === 'join' && body.ptype === 'subscriber') return { plugindata: { data: { videoroom: 'attached' } }, jsep: { type: 'offer', sdp: 'fixture' } };
|
|
641
|
+
return {};
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const make = managedRoom => new KazeeCallSession({ roomId: 7, chatId: 'chat', callerId: userId, botUserId: botId, managedRoom,
|
|
645
|
+
roomToken: 'fixture', roomTokenExpiresAt: Date.now() + 300000,
|
|
646
|
+
iceServers: [{ urls: 'turn:relay:3478', username: 'fixture', credential: 'fixture' }], iceCredentialsExpiresAt: Date.now() + 3600000,
|
|
647
|
+
JanusClientImpl: MetadataJanus, PeerConnectionImpl: FakePC, SpeechStreamImpl: FakeSpeechStream });
|
|
648
|
+
const invalid = [undefined, null, {}, { kazee: null }, { kazee: [] },
|
|
649
|
+
{ kazee: { version: 2, userId, principalKind: 'User' } }, { kazee: { version: '1', userId, principalKind: 'User' } },
|
|
650
|
+
metadata('Human'), metadata('bot'), metadata(undefined), metadata('User', 'invalid'), { kazee: { version: 1, principalKind: 'User' } },
|
|
651
|
+
{ kazee: { version: 1, userId: { $ne: null }, principalKind: 'User' } }];
|
|
652
|
+
initial = [publisher(41, metadata('User')), publisher(42, metadata('Bot', 'c'.repeat(24)), 'other-bot@calling.invalid'),
|
|
653
|
+
publisher(43, metadata('User')), publisher(44, metadata('User', botId.toUpperCase())),
|
|
654
|
+
...invalid.map((meta, i) => publisher(100 + i, meta))];
|
|
655
|
+
const managed = make(true);
|
|
656
|
+
try {
|
|
657
|
+
await managed.start();
|
|
658
|
+
assert.deepStrictEqual([...managed.subscribers.keys()], [43], 'initial join reaches real subscription only for a classified human, never self/bot/unknown');
|
|
659
|
+
assert.strictEqual(managed.subscribers.get(43).started, true, 'authorized human reaches the actual subscriber offer/answer path');
|
|
660
|
+
const emit = publishers => managed.janus.listeners.get(managed.publisherHandle)({ janus: 'event', plugindata: { data: { publishers } } });
|
|
661
|
+
await emit([publisher(50, metadata('Bot', 'd'.repeat(24)), 'another-bot@calling.invalid'),
|
|
662
|
+
publisher(51, metadata('User'), 'open-claudia:display-is-not-managed-authority'),
|
|
663
|
+
...invalid.map((meta, i) => publisher(200 + i, meta))]);
|
|
664
|
+
assert.deepStrictEqual([...managed.subscribers.keys()], [43, 51], 'live publisher metadata survives forwarding; managed classification never falls back to display prefix');
|
|
665
|
+
assert.strictEqual(managed.subscribers.get(51).display, 'open-claudia:display-is-not-managed-authority', 'presentation remains unchanged');
|
|
666
|
+
const handles = managed.janus.handles;
|
|
667
|
+
await managed._subscribe(60, 'unclassified@calling.invalid');
|
|
668
|
+
await managed._subscribe(61, 'bot@calling.invalid', metadata('Bot'));
|
|
669
|
+
await managed._subscribe(41, 'own@calling.invalid', metadata('User'));
|
|
670
|
+
assert.strictEqual(managed.janus.handles, handles, 'the subscription method itself fails closed before creating handles or speech streams');
|
|
671
|
+
assert.deepStrictEqual(managed.janus.calls.filter(body => body.ptype === 'subscriber').map(body => body.feed), [43, 51]);
|
|
672
|
+
} finally { await managed.close(); }
|
|
673
|
+
|
|
674
|
+
initial = [publisher(41, metadata('User')), publisher(42, metadata('User'), 'open-claudia:legacy-bot'),
|
|
675
|
+
publisher(43, metadata('Bot'), 'human@calling.invalid')];
|
|
676
|
+
const legacy = make(false);
|
|
677
|
+
try {
|
|
678
|
+
await legacy.start();
|
|
679
|
+
assert.deepStrictEqual([...legacy.subscribers.keys()], [43], 'legacy metadata cannot hide a human or impersonate one for a prefixed bot; own numeric feed remains excluded');
|
|
680
|
+
await legacy.janus.listeners.get(legacy.publisherHandle)({ janus: 'event', plugindata: { data: { publishers: [
|
|
681
|
+
publisher(50, metadata('Bot'), 'untrusted-kind@calling.invalid'), publisher(51, metadata('User'), 'open-claudia:other-bot'),
|
|
682
|
+
publisher(52, undefined, 'older-client@calling.invalid'),
|
|
683
|
+
] } } });
|
|
684
|
+
assert.deepStrictEqual([...legacy.subscribers.keys()], [43, 50, 52], 'legacy live publishers keep display-prefix behavior regardless of spoofed or absent metadata');
|
|
685
|
+
} finally { await legacy.close(); }
|
|
686
|
+
}
|
|
687
|
+
|
|
487
688
|
async function testAssignedRelayTransportLifetime() {
|
|
488
689
|
const { RTCPeerConnection } = require('werift');
|
|
489
690
|
const originalExpiry = Date.now() + 3600000;
|
|
@@ -538,6 +739,7 @@ async function testAssignedRelayTransportLifetime() {
|
|
|
538
739
|
}
|
|
539
740
|
|
|
540
741
|
(async () => {
|
|
742
|
+
await testTrustedPublisherMetadata();
|
|
541
743
|
await testAssignedRelayTransportLifetime();
|
|
542
744
|
await testManagedRoomCredentials();
|
|
543
745
|
await testSpeakSerialization();
|
|
@@ -549,7 +751,12 @@ async function testAssignedRelayTransportLifetime() {
|
|
|
549
751
|
await testHoldTimeoutDropsNoiseTurn();
|
|
550
752
|
await testWordStallRescue();
|
|
551
753
|
await testInterimFallbackAndLateFinal();
|
|
754
|
+
await testNoiseAfterFallbackCannotReplayPreviousUtterance();
|
|
755
|
+
await testVersionedFinalsBelongToTheirCommittedSegment();
|
|
552
756
|
await testTrickleBuffering();
|
|
757
|
+
await testSemanticPauseResumesWithoutSplittingAudio();
|
|
758
|
+
await testTurnModelFailureAndCloseAreBounded();
|
|
759
|
+
await testCancelStopsPendingTtsRequest();
|
|
553
760
|
await testTranscriptAttribution();
|
|
554
761
|
await testIdleTimeout();
|
|
555
762
|
await testIdleBargeInKeepsCall();
|
|
@@ -560,5 +767,6 @@ async function testAssignedRelayTransportLifetime() {
|
|
|
560
767
|
await testCallerModeRoomExistsAndFailure();
|
|
561
768
|
await testThinkingCues();
|
|
562
769
|
await testCloseResolvesSpeech();
|
|
770
|
+
await testDelayedSpeechProtocolReady();
|
|
563
771
|
console.log("kazee call session: ok");
|
|
564
772
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|