@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.
@@ -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
- await f.manager.ended({ roomId: 12 });
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, "released commit acks and thinks like a normal turn");
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
- // Caller finishes a sentence: speech then sustained silence commits the turn,
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 > 0, "commit plays the ack chime");
403
- assert(session._thinkTimer, "thinking loop starts at commit, before the transcript");
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 is excluded independently of canonical display; legacy bot exclusion remains');
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
- await Promise.all([session._subscribe(17, "owner"), session._subscribe(18, "other")]);
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; });