@inetafrica/open-claudia 3.5.47 → 3.5.59
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 +70 -10
- package/bin/cli.js +10 -3
- package/bot.js +11 -0
- package/channels/kazee/adapter.js +244 -23
- package/channels/kazee/call/camera.js +233 -0
- package/channels/kazee/call/job-steps.js +81 -0
- package/channels/kazee/call/manager.js +1056 -27
- package/channels/kazee/call/miss-capture.js +82 -0
- package/channels/kazee/call/quick-voice.js +610 -0
- package/channels/kazee/call/session.js +787 -36
- package/channels/kazee/call/thread-context.js +100 -0
- package/channels/kazee/catch-up.js +275 -0
- package/core/actions.js +13 -4
- package/core/enforcer.js +3 -0
- package/core/handlers.js +14 -16
- package/core/identity.js +15 -1
- package/core/loopback.js +4 -1
- package/core/media.js +39 -1
- package/core/relationship.js +41 -18
- package/core/run-context.js +27 -3
- package/core/runner.js +70 -1
- package/core/state.js +6 -2
- package/core/switchboard/brain-link.js +161 -0
- package/core/switchboard/call-host.js +298 -0
- package/core/switchboard/handoff.js +80 -0
- package/core/switchboard/ipc.js +130 -0
- package/core/switchboard/supervisor.js +225 -0
- package/core/system-prompt.js +64 -12
- package/core/transcripts.js +39 -0
- package/core/tts-voice.js +62 -4
- package/core/turn-journal.js +23 -1
- package/core/utility-agent.js +5 -2
- package/package.json +2 -2
- package/test-call-speech.js +7 -0
- package/test-external-rule-scopes.js +24 -0
- package/test-fixtures/switchboard-fake-brain.js +20 -0
- package/test-kazee-call-manager.js +22 -4
- package/test-kazee-call-session.js +345 -8
- package/test-provider-compaction.js +8 -0
- package/test-provider-language.js +19 -9
- package/test-provider-prompt-parity.js +1 -0
- package/test-provider-runner.js +2 -0
- package/test-run-lock.js +24 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
const assert = require("assert");
|
|
5
5
|
const { EventEmitter } = require("events");
|
|
6
|
-
const { KazeeCallSession } = require("./channels/kazee/call/session");
|
|
6
|
+
const { KazeeCallSession, realWords, newHeard, noteHeard, heardSummary } = require("./channels/kazee/call/session");
|
|
7
7
|
const { SpeechStream } = require("./channels/kazee/call/speech-client");
|
|
8
8
|
const { KazeeCallManager } = require("./channels/kazee/call/manager");
|
|
9
9
|
const { OpusAudioCodec, WEBRTC_SAMPLES } = require("./channels/kazee/call/audio");
|
|
@@ -65,6 +65,43 @@ function toneOpus(codec, amplitude) {
|
|
|
65
65
|
return codec.encode(pcm);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// 13 Oct: a call heard no words while packets arrived, and the log could not
|
|
69
|
+
// say why. The close log now says how loud the caller came in, how many
|
|
70
|
+
// packets failed to decode, and how often the voice gate opened.
|
|
71
|
+
async function testHeardSummary() {
|
|
72
|
+
const empty = newHeard();
|
|
73
|
+
assert.strictEqual(heardSummary(empty, -50), "frames=0 decode-errors=0 p10=none p50=none p90=none max=none voice-starts=0 gate=-50dB");
|
|
74
|
+
const h = newHeard();
|
|
75
|
+
for (let i = 0; i < 9; i += 1) noteHeard(h, -70.4);
|
|
76
|
+
noteHeard(h, -20.6);
|
|
77
|
+
noteHeard(h, -Infinity);
|
|
78
|
+
assert.strictEqual(heardSummary(h, -47.6), "frames=11 decode-errors=0 p10=-70dB p50=-70dB p90=-70dB max=-21dB voice-starts=0 gate=-48dB");
|
|
79
|
+
|
|
80
|
+
const { session } = makeSession();
|
|
81
|
+
const logs = [];
|
|
82
|
+
const log = console.log;
|
|
83
|
+
console.log = (...args) => { logs.push(args.join(" ")); };
|
|
84
|
+
try {
|
|
85
|
+
await session._subscribe(902, "caller");
|
|
86
|
+
const codec = new OpusAudioCodec();
|
|
87
|
+
for (let i = 0; i < 20; i += 1) session._onFeedRtp(902, { payload: toneOpus(codec, 30) });
|
|
88
|
+
for (let i = 0; i < 10; i += 1) session._onFeedRtp(902, { payload: toneOpus(codec, 12000) });
|
|
89
|
+
session._onFeedRtp(902, { payload: Buffer.alloc(2000, 0xff) });
|
|
90
|
+
codec.close();
|
|
91
|
+
await session._removeSubscriber(902);
|
|
92
|
+
} finally {
|
|
93
|
+
console.log = log;
|
|
94
|
+
await session.close();
|
|
95
|
+
}
|
|
96
|
+
const line = logs.find((l) => l.includes("Kazee call heard: feed=902"));
|
|
97
|
+
assert.ok(line, "the close log says what was heard");
|
|
98
|
+
assert.match(line, /frames=30 decode-errors=1 /);
|
|
99
|
+
assert.match(line, /voice-starts=1 /, "the loud tone opened the voice gate once");
|
|
100
|
+
const max = Number(line.match(/max=(-?\d+)dB/)[1]);
|
|
101
|
+
const p10 = Number(line.match(/p10=(-?\d+)dB/)[1]);
|
|
102
|
+
assert.ok(max > -20 && p10 < -50, `quiet and loud frames both show (${line})`);
|
|
103
|
+
}
|
|
104
|
+
|
|
68
105
|
async function testDelayedSpeechProtocolReady() {
|
|
69
106
|
class DelayedWebSocket extends EventEmitter {
|
|
70
107
|
static OPEN = 1;
|
|
@@ -166,23 +203,89 @@ async function testEchoGateAndBargeIn() {
|
|
|
166
203
|
const { session } = makeSession({
|
|
167
204
|
synthesize: async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 100), sampleRate: 48000 }),
|
|
168
205
|
});
|
|
206
|
+
let starts = 0;
|
|
207
|
+
session.onSpeechStart = () => { starts += 1; };
|
|
169
208
|
await session._subscribe(101, "sumeet@kazee");
|
|
170
209
|
const sub = session.subscribers.get(101);
|
|
171
210
|
const gen = new OpusAudioCodec();
|
|
172
211
|
session.publisher = { track: { writeRtp: () => {} }, sender: { codec: { payloadType: 111 } } };
|
|
173
|
-
const
|
|
212
|
+
const line = "Here is the long answer about the mail queue backlog.";
|
|
213
|
+
const playing = session.speak(line, { resumable: true });
|
|
174
214
|
await wait(30);
|
|
175
215
|
assert.strictEqual(session.speaking, true);
|
|
176
216
|
for (let i = 0; i < 6; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 1500) }); // ~-30dB: echo territory
|
|
177
217
|
assert.strictEqual(session.speaking, true, "echo-level audio does not interrupt TTS");
|
|
178
218
|
assert.strictEqual(sub.stt.audioFrames.length, 0, "echo-level audio is not streamed to STT");
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
219
|
+
// 11 Oct 17:43: chewing stopped every reply. A loud sound is listened to,
|
|
220
|
+
// never obeyed: speech goes on until words come through.
|
|
221
|
+
for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) }); // ~-12dB: loud
|
|
222
|
+
assert(sub.stt.audioFrames.length >= 3, "the sound reaches STT");
|
|
223
|
+
assert.strictEqual(session.speaking, true, "a sound alone does not stop speech");
|
|
224
|
+
assert.strictEqual(session.callerTalking(), false, "a sound alone is not the caller talking");
|
|
225
|
+
sub.stt.onPartial("mm");
|
|
226
|
+
sub.stt.onPartial("mm hmm");
|
|
227
|
+
assert.strictEqual(session.speaking, true, "fillers do not stop speech");
|
|
228
|
+
sub.stt.onPartial("the long answer about");
|
|
229
|
+
assert.strictEqual(session.speaking, true, "our own line coming back does not stop speech");
|
|
230
|
+
assert.strictEqual(starts, 0, "nothing told the manager the caller started");
|
|
231
|
+
sub.stt.onPartial("the long answer about wait stop");
|
|
232
|
+
assert.strictEqual(await playing, false, "two real words stop speech");
|
|
233
|
+
assert.strictEqual(starts, 1, "the manager hears the caller started, once");
|
|
234
|
+
assert.strictEqual(session.callerTalking(), true);
|
|
235
|
+
assert.deepStrictEqual(session.cutSpeech(), { text: line, fromStart: true }, "the unheard line is kept");
|
|
236
|
+
// The rest of an answer the quick voice was still writing (manager.js
|
|
237
|
+
// _sayRelayed) goes behind the cut words; a held reply goes in front.
|
|
238
|
+
session.keepUnsaid("And the backlog is clearing.", { after: true });
|
|
239
|
+
assert.deepStrictEqual(session.cutSpeech(), { text: `${line} And the backlog is clearing.`, fromStart: true });
|
|
240
|
+
session.keepUnsaid("Still here.");
|
|
241
|
+
assert.strictEqual(session.cutSpeech().text, `Still here. ${line} And the backlog is clearing.`);
|
|
242
|
+
gen.close();
|
|
243
|
+
await session.close();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Said over us, below the word gate ("yeah" while we talk): not a turn while
|
|
247
|
+
// we are still talking; once quiet, a one-word answer goes through.
|
|
248
|
+
async function testWordsUnderSpeechDropped() {
|
|
249
|
+
const { session, transcripts } = makeSession({
|
|
250
|
+
synthesize: async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 200), sampleRate: 48000 }),
|
|
251
|
+
});
|
|
252
|
+
await session._subscribe(101, "caller");
|
|
253
|
+
const sub = session.subscribers.get(101);
|
|
254
|
+
const gen = new OpusAudioCodec();
|
|
255
|
+
session.publisher = { track: { writeRtp: () => {} }, sender: { codec: { payloadType: 111 } } };
|
|
256
|
+
const playing = session.speak("A long answer that keeps going for a while.");
|
|
257
|
+
await wait(30);
|
|
258
|
+
for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
|
|
259
|
+
sub.stt.onPartial("yeah");
|
|
260
|
+
for (let i = 0; i < 36; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 30) });
|
|
261
|
+
assert.strictEqual(sub.stt.commits, 1);
|
|
262
|
+
sub.stt.onTranscript("Yeah.");
|
|
263
|
+
assert.strictEqual(transcripts.length, 0, "a 'yeah' under our speech is not a turn");
|
|
264
|
+
assert.strictEqual(session.speaking, true, "and speech carries on");
|
|
265
|
+
session.cancelSpeech();
|
|
266
|
+
await playing;
|
|
267
|
+
await wait(30);
|
|
268
|
+
for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
|
|
269
|
+
sub.stt.onPartial("yes");
|
|
270
|
+
assert.strictEqual(session.callerTalking(), true, "one real word when we are quiet is talking");
|
|
271
|
+
for (let i = 0; i < 36; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 30) });
|
|
272
|
+
sub.stt.onTranscript("Yes.");
|
|
273
|
+
assert.deepStrictEqual(transcripts.map((item) => item.text), ["Yes."], "once quiet, a short answer goes through");
|
|
182
274
|
gen.close();
|
|
183
275
|
await session.close();
|
|
184
276
|
}
|
|
185
277
|
|
|
278
|
+
function testRealWords() {
|
|
279
|
+
assert.strictEqual(realWords("um uh mm hmm"), 0, "fillers");
|
|
280
|
+
assert.strictEqual(realWords("Oh, wait."), 1);
|
|
281
|
+
assert.strictEqual(realWords("wait stop that"), 3);
|
|
282
|
+
const ours = "The mail queue has forty messages waiting.";
|
|
283
|
+
assert.strictEqual(realWords("the mail queue has", ours), 0, "our own line coming back");
|
|
284
|
+
assert.strictEqual(realWords("what's in the mail queue", ours), 2, "a question about the same thing is the caller");
|
|
285
|
+
assert.strictEqual(realWords("the mail queue has wait stop", ours), 2, "the caller's words after our echo count");
|
|
286
|
+
assert.strictEqual(realWords("hang on", ours), 2);
|
|
287
|
+
}
|
|
288
|
+
|
|
186
289
|
async function testPrebufferAndCommit() {
|
|
187
290
|
const { session } = makeSession({});
|
|
188
291
|
await session._subscribe(101, "caller");
|
|
@@ -218,6 +321,8 @@ async function testHoldForWords() {
|
|
|
218
321
|
|
|
219
322
|
async function testHoldTimeoutDropsNoiseTurn() {
|
|
220
323
|
const { session, transcripts } = makeSession({});
|
|
324
|
+
const noise = [];
|
|
325
|
+
session.onSpeechNoise = (meta) => noise.push(meta);
|
|
221
326
|
await session._subscribe(101, "caller");
|
|
222
327
|
const sub = session.subscribers.get(101);
|
|
223
328
|
const gen = new OpusAudioCodec();
|
|
@@ -230,6 +335,7 @@ async function testHoldTimeoutDropsNoiseTurn() {
|
|
|
230
335
|
assert.strictEqual(session._thinkTimer, null, "noise commit stays silent — no ack, no ticking");
|
|
231
336
|
sub.stt.onTranscript("uh"); // babble leaked a stray word into the transcript
|
|
232
337
|
assert.strictEqual(transcripts.length, 0, "sub-threshold noise transcript never dispatches");
|
|
338
|
+
assert.deepStrictEqual(noise, [{ feedId: 101 }], "a turn with no words reports noise, so a muted answer can speak again");
|
|
233
339
|
gen.close();
|
|
234
340
|
await session.close();
|
|
235
341
|
}
|
|
@@ -318,6 +424,38 @@ async function testVersionedFinalsBelongToTheirCommittedSegment() {
|
|
|
318
424
|
await session.close();
|
|
319
425
|
}
|
|
320
426
|
|
|
427
|
+
// At a versioned commit the words heard so far go out at once, tagged with
|
|
428
|
+
// the segment the final will carry (manager.js _earlyWords); a committed
|
|
429
|
+
// segment that never reaches onTranscript is reported dropped.
|
|
430
|
+
async function testEarlyWordsAtCommit() {
|
|
431
|
+
const { session, transcripts } = makeSession({ finalWaitMs: 30 });
|
|
432
|
+
const early = [];
|
|
433
|
+
const dropped = [];
|
|
434
|
+
session.onEarlyWords = (text, meta) => early.push({ text, meta });
|
|
435
|
+
session.onUtteranceDropped = (id) => dropped.push(id);
|
|
436
|
+
await session._subscribe(101, "caller");
|
|
437
|
+
const sub = session.subscribers.get(101);
|
|
438
|
+
Object.assign(sub.stt, { protocol: 2, streamId: 1, segmentId: 1 });
|
|
439
|
+
sub.stt.commitTurn = () => ({ streamId: 1, segmentId: sub.stt.segmentId++, pending: Promise.resolve() });
|
|
440
|
+
session._onFeedPartial(101, "check the deploy", { protocol: 2, streamId: 1, segmentId: 1 });
|
|
441
|
+
session._commitTurn(101, "silence");
|
|
442
|
+
assert.deepStrictEqual(early.map(e => [e.text, e.meta.utteranceId]), [["check the deploy", "1:1"]]);
|
|
443
|
+
assert.strictEqual(transcripts.length, 0, "the turn itself still waits for the final");
|
|
444
|
+
session._onFeedFinal(101, "Check the deploy.", { protocol: 2, streamId: 1, segmentId: 1 });
|
|
445
|
+
assert.strictEqual(transcripts[0].meta.utteranceId, "1:1");
|
|
446
|
+
// One word is not enough to start on.
|
|
447
|
+
session._onFeedPartial(101, "yeah", { protocol: 2, streamId: 1, segmentId: 2 });
|
|
448
|
+
session._commitTurn(101, "silence");
|
|
449
|
+
assert.strictEqual(early.length, 1);
|
|
450
|
+
// A noise commit: no early words; its empty final is reported dropped.
|
|
451
|
+
sub.noiseCommit = true;
|
|
452
|
+
session._commitTurn(101, "no-words");
|
|
453
|
+
assert.strictEqual(early.length, 1);
|
|
454
|
+
session._onFeedFinal(101, "", { protocol: 2, streamId: 1, segmentId: 3 });
|
|
455
|
+
assert.deepStrictEqual(dropped, ["1:3"]);
|
|
456
|
+
await session.close();
|
|
457
|
+
}
|
|
458
|
+
|
|
321
459
|
async function testSemanticPauseResumesWithoutSplittingAudio() {
|
|
322
460
|
const { session } = makeSession();
|
|
323
461
|
const decisions = [];
|
|
@@ -354,6 +492,52 @@ async function testSemanticPauseResumesWithoutSplittingAudio() {
|
|
|
354
492
|
await session.close();
|
|
355
493
|
}
|
|
356
494
|
|
|
495
|
+
// The live speech service (protocol 2): chewing over our speech is a turn
|
|
496
|
+
// with no words, and speech goes on; a "yeah" is dropped; real words stop it.
|
|
497
|
+
async function testVersionedWordsNotSound() {
|
|
498
|
+
const { session, transcripts } = makeSession({
|
|
499
|
+
synthesize: async () => ({ pcm: Buffer.alloc(WEBRTC_SAMPLES * 2 * 400), sampleRate: 48000 }),
|
|
500
|
+
});
|
|
501
|
+
session.turnDecision = async () => ({ complete: true });
|
|
502
|
+
let starts = 0, noises = 0;
|
|
503
|
+
session.onSpeechStart = () => { starts += 1; };
|
|
504
|
+
session.onSpeechNoise = () => { noises += 1; };
|
|
505
|
+
await session._subscribe(101, "caller");
|
|
506
|
+
const sub = session.subscribers.get(101);
|
|
507
|
+
Object.assign(sub.stt, { protocol: 2, streamId: 1, segmentId: 1 });
|
|
508
|
+
sub.stt.commitTurn = () => { sub.stt.commits++; return { streamId: 1, segmentId: sub.stt.segmentId++, pending: Promise.resolve() }; };
|
|
509
|
+
session.publisher = { track: { writeRtp: () => {} }, sender: { codec: { payloadType: 111 } } };
|
|
510
|
+
const codec = new OpusAudioCodec();
|
|
511
|
+
const sound = () => { for (let i = 0; i < 3; i++) session._onFeedRtp(101, { payload: toneOpus(codec, 12000) }); };
|
|
512
|
+
const silence = () => { for (let i = 0; i < 36; i++) session._onFeedRtp(101, { payload: toneOpus(codec, 30) }); };
|
|
513
|
+
const playing = session.speak("Here is the whole answer, which takes a few seconds to say.", { resumable: true });
|
|
514
|
+
await wait(30);
|
|
515
|
+
sound(); silence();
|
|
516
|
+
await wait(5);
|
|
517
|
+
assert.strictEqual(sub.stt.commits, 1, "chewing commits a turn");
|
|
518
|
+
session._onFeedFinal(101, "", { protocol: 2, streamId: 1, segmentId: 1 });
|
|
519
|
+
assert.strictEqual(session.speaking, true, "chewing does not stop speech");
|
|
520
|
+
assert.strictEqual(starts, 0);
|
|
521
|
+
sound();
|
|
522
|
+
session._onFeedPartial(101, "yeah", { protocol: 2, streamId: 1, segmentId: 2 });
|
|
523
|
+
silence();
|
|
524
|
+
await wait(5);
|
|
525
|
+
session._onFeedFinal(101, "Yeah.", { protocol: 2, streamId: 1, segmentId: 2 });
|
|
526
|
+
assert.strictEqual(transcripts.length, 0, "a 'yeah' under our speech is not a turn");
|
|
527
|
+
assert.strictEqual(session.speaking, true);
|
|
528
|
+
sound();
|
|
529
|
+
session._onFeedPartial(101, "hang on", { protocol: 2, streamId: 1, segmentId: 3 });
|
|
530
|
+
assert.strictEqual(await playing, false, "real words stop speech");
|
|
531
|
+
assert.strictEqual(starts, 1);
|
|
532
|
+
assert.strictEqual(session.callerTalking(), true);
|
|
533
|
+
silence();
|
|
534
|
+
await wait(5);
|
|
535
|
+
session._onFeedFinal(101, "Hang on a second.", { protocol: 2, streamId: 1, segmentId: 3 });
|
|
536
|
+
assert.deepStrictEqual(transcripts.map((t) => t.text), ["Hang on a second."]);
|
|
537
|
+
codec.close();
|
|
538
|
+
await session.close();
|
|
539
|
+
}
|
|
540
|
+
|
|
357
541
|
async function testTurnModelFailureAndCloseAreBounded() {
|
|
358
542
|
const { SpeechTurnGate } = require("./channels/kazee/call/turn-gate");
|
|
359
543
|
const commits = [];
|
|
@@ -403,7 +587,7 @@ async function testTranscriptAttribution() {
|
|
|
403
587
|
}
|
|
404
588
|
const inbound = [];
|
|
405
589
|
const adapter = { ownerUserId: "owner-1", botUserId: "bot-1", _socket: { on() {}, emit() {} }, _emit: (name, payload) => inbound.push(payload) };
|
|
406
|
-
const manager = new KazeeCallManager({ adapter, SessionImpl: FakeSession });
|
|
590
|
+
const manager = new KazeeCallManager({ adapter, SessionImpl: FakeSession, quickSettings: () => ({ enabled: false }) });
|
|
407
591
|
await manager.incoming({ fromUserId: "owner-1", chatId: "c", roomId: 5, callType: "audio" });
|
|
408
592
|
manager.session.options.onTranscript("hi", { feedId: 1, display: "alice@kazee", participants: 2 });
|
|
409
593
|
assert.strictEqual(inbound[0].text, "alice@kazee: hi", "group transcripts are speaker-prefixed");
|
|
@@ -506,6 +690,42 @@ async function testCallerModeRoomLifecycle() {
|
|
|
506
690
|
assert.ok(destroy && destroy.room === 7, "created room is destroyed on close");
|
|
507
691
|
}
|
|
508
692
|
|
|
693
|
+
// A prepared opening line replaces the fixed greeting; an empty or failed one
|
|
694
|
+
// falls back to it, and a call that closed first stays silent.
|
|
695
|
+
async function testPreparedGreetingLine() {
|
|
696
|
+
for (const [line, expected] of [
|
|
697
|
+
[() => Promise.resolve("Hey Sumeet, still on the calls update? What's up?"), ["Hey Sumeet, still on the calls update? What's up?"]],
|
|
698
|
+
[() => Promise.resolve(""), ["fixed hello"]],
|
|
699
|
+
[() => Promise.reject(new Error("quick voice gone")), ["fixed hello"]],
|
|
700
|
+
]) {
|
|
701
|
+
const { session } = makeSession({ greeting: "fixed hello" });
|
|
702
|
+
const spoken = [];
|
|
703
|
+
session.speak = async (text) => { spoken.push(text); return true; };
|
|
704
|
+
session.greetingLine = line();
|
|
705
|
+
session._publisherUp = true;
|
|
706
|
+
session.subscribers.set(101, {});
|
|
707
|
+
session._maybeGreet();
|
|
708
|
+
session._maybeGreet();
|
|
709
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
710
|
+
assert.deepStrictEqual(spoken, expected);
|
|
711
|
+
session.subscribers.clear();
|
|
712
|
+
await session.close();
|
|
713
|
+
}
|
|
714
|
+
const { session } = makeSession({ greeting: "fixed hello" });
|
|
715
|
+
const spoken = [];
|
|
716
|
+
session.speak = async (text) => { spoken.push(text); return true; };
|
|
717
|
+
let release;
|
|
718
|
+
session.greetingLine = new Promise((resolve) => { release = resolve; });
|
|
719
|
+
session._publisherUp = true;
|
|
720
|
+
session.subscribers.set(101, {});
|
|
721
|
+
session._maybeGreet();
|
|
722
|
+
session.subscribers.clear();
|
|
723
|
+
await session.close();
|
|
724
|
+
release("Hey Sumeet, what's up?");
|
|
725
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
726
|
+
assert.deepStrictEqual(spoken, [], "no greeting into a call that already ended");
|
|
727
|
+
}
|
|
728
|
+
|
|
509
729
|
async function testCallerModeRoomExistsAndFailure() {
|
|
510
730
|
const { session } = makeSession({ asCaller: true });
|
|
511
731
|
session.janus.message = async (_handle, body) => body?.request === "create"
|
|
@@ -560,10 +780,13 @@ async function testThinkingCues() {
|
|
|
560
780
|
await session.speak("answer");
|
|
561
781
|
assert.strictEqual(session._thinkTimer, null, "answer playback stops the thinking loop");
|
|
562
782
|
|
|
563
|
-
// A fresh thinking loop stops the moment the caller starts talking again
|
|
783
|
+
// A fresh thinking loop stops the moment the caller starts talking again:
|
|
784
|
+
// on their words, not on a sound.
|
|
564
785
|
session._startThinking("another question");
|
|
565
786
|
for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) });
|
|
566
|
-
assert
|
|
787
|
+
assert(session._thinkTimer, "a sound alone leaves the thinking loop");
|
|
788
|
+
sub.stt.onPartial("hold on");
|
|
789
|
+
assert.strictEqual(session._thinkTimer, null, "caller words stop the thinking loop");
|
|
567
790
|
gen.close();
|
|
568
791
|
await session.close();
|
|
569
792
|
}
|
|
@@ -625,6 +848,113 @@ async function testManagedRoomCredentials() {
|
|
|
625
848
|
assert.ok(!session.janus.calls.some(body => ["create", "destroy"].includes(body.request)), "server allocated room ownership never transfers to the bot");
|
|
626
849
|
}
|
|
627
850
|
|
|
851
|
+
// The caller's camera (camera.js): a feed's connection takes their video
|
|
852
|
+
// with the orientation extension, a camera switched on mid-call is offered
|
|
853
|
+
// again by Janus and answered, and a failed re-offer keeps the call's audio.
|
|
854
|
+
async function testCallerCamera() {
|
|
855
|
+
const userId = 'a'.repeat(24);
|
|
856
|
+
const pcs = [];
|
|
857
|
+
class CameraPC extends FakePC {
|
|
858
|
+
constructor(options) {
|
|
859
|
+
super(options);
|
|
860
|
+
pcs.push(this);
|
|
861
|
+
this.remote = [];
|
|
862
|
+
this.trackListeners = [];
|
|
863
|
+
this.onTrack = { subscribe: (fn) => this.trackListeners.push(fn) };
|
|
864
|
+
}
|
|
865
|
+
async setRemoteDescription(description) {
|
|
866
|
+
if (this.failNext) { this.failNext = false; throw new Error('renegotiation unsupported'); }
|
|
867
|
+
this.remote.push(description);
|
|
868
|
+
}
|
|
869
|
+
async createAnswer() { return { type: 'answer', sdp: `answer-${this.remote.length}` }; }
|
|
870
|
+
async setLocalDescription(description) { this.localDescription = description; }
|
|
871
|
+
getTransceivers() { return this.transceivers || []; }
|
|
872
|
+
}
|
|
873
|
+
class CameraJanus extends FakeJanus {
|
|
874
|
+
constructor() { super(); this.calls = []; this.listeners = new Map(); }
|
|
875
|
+
onHandle(handle, listener) { this.listeners.set(handle, listener); }
|
|
876
|
+
async message(handle, body, jsep) {
|
|
877
|
+
this.calls.push({ handle, ...body, jsep });
|
|
878
|
+
if (body.request === 'join' && body.ptype === 'publisher') return { plugindata: { data: { videoroom: 'joined', id: 41, private_id: 9, publishers: [{ id: 43, display: 'caller@calling.invalid', metadata: { kazee: { version: 1, userId, principalKind: 'User' } } }] } } };
|
|
879
|
+
if (body.request === 'configure') return { jsep: { type: 'answer', sdp: 'fixture' } };
|
|
880
|
+
if (body.request === 'join' && body.ptype === 'subscriber') {
|
|
881
|
+
// Real Janus also sends the join's offer as a handle event.
|
|
882
|
+
const attached = { janus: 'event', plugindata: { data: { videoroom: 'attached', streams: [{ mindex: 0, mid: '0', type: 'audio', feed_id: 43, feed_mid: '0' }] } }, jsep: { type: 'offer', sdp: 'v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:0' } };
|
|
883
|
+
setImmediate(() => this.listeners.get(handle)?.(attached));
|
|
884
|
+
return attached;
|
|
885
|
+
}
|
|
886
|
+
// Asked for the caller's camera: Janus offers the feed again.
|
|
887
|
+
if (body.request === 'update') return { plugindata: { data: { videoroom: 'updated', streams: [
|
|
888
|
+
{ mindex: 0, mid: '0', type: 'audio', feed_id: 43, feed_mid: '0' }, { mindex: 1, mid: '1', type: 'video', feed_id: 43, feed_mid: '1' }] } },
|
|
889
|
+
jsep: { type: 'offer', sdp: 'v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=mid:1\r\na=extmap:5 urn:3gpp:video-orientation' } };
|
|
890
|
+
return {};
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
const session = new KazeeCallSession({ roomId: 8, chatId: 'chat', callerId: userId, botUserId: 'b'.repeat(24), managedRoom: true,
|
|
894
|
+
roomToken: 'fixture', roomTokenExpiresAt: Date.now() + 300000,
|
|
895
|
+
iceServers: [{ urls: 'turn:relay:3478', username: 'fixture', credential: 'fixture' }], iceCredentialsExpiresAt: Date.now() + 3600000,
|
|
896
|
+
JanusClientImpl: CameraJanus, PeerConnectionImpl: CameraPC, SpeechStreamImpl: FakeSpeechStream });
|
|
897
|
+
try {
|
|
898
|
+
await session.start();
|
|
899
|
+
const sub = session.subscribers.get(43);
|
|
900
|
+
assert.ok(sub?.started, 'the caller is subscribed');
|
|
901
|
+
const feedPc = sub.pc;
|
|
902
|
+
assert.deepStrictEqual(feedPc.options.headerExtensions.video.map((ext) => ext.uri), ['urn:3gpp:video-orientation'], 'the feed asks for the phone\'s orientation');
|
|
903
|
+
assert.strictEqual(pcs.find((pc) => pc !== feedPc && !pc.options.headerExtensions) != null, true, 'the bot\'s own connection is unchanged');
|
|
904
|
+
assert.strictEqual(session.cameraStill(), null, 'no still before any video');
|
|
905
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
906
|
+
assert.strictEqual(feedPc.remote.length, 1, 'the join\'s offer arriving twice is answered once');
|
|
907
|
+
assert.strictEqual(session.janus.calls.filter((call) => call.handle === sub.handle && call.request === 'start').length, 1, 'one start for the join');
|
|
908
|
+
|
|
909
|
+
// Camera switched on mid-call: Janus 1.4 tells only the publishers
|
|
910
|
+
// (11 Oct 17:04: the camera was on and no video reached the bot), so the
|
|
911
|
+
// bot asks for the new video with update.
|
|
912
|
+
const listener = session.janus.listeners.get(sub.handle);
|
|
913
|
+
const publishers = (streams) => session.janus.listeners.get(session.publisherHandle)({ janus: 'event', plugindata: { data: { videoroom: 'event', publishers: [{ id: 43, display: 'caller@calling.invalid', streams }] } } });
|
|
914
|
+
const updates = () => session.janus.calls.filter((call) => call.handle === sub.handle && call.request === 'update');
|
|
915
|
+
await publishers([{ type: 'audio', mid: '0' }, { type: 'video', mid: '1', disabled: true }]);
|
|
916
|
+
assert.strictEqual(updates().length, 0, 'a camera still off is not asked for');
|
|
917
|
+
await publishers([{ type: 'audio', mid: '0' }, { type: 'video', mid: '1' }]);
|
|
918
|
+
assert.deepStrictEqual(updates().map((call) => call.subscribe), [[{ feed: 43, mid: '1' }]], 'only the new video is asked for');
|
|
919
|
+
assert.strictEqual(feedPc.remote.length, 2, 'the new offer is applied');
|
|
920
|
+
const start = session.janus.calls.filter((call) => call.handle === sub.handle && call.request === 'start');
|
|
921
|
+
assert.strictEqual(start.length, 2, 'and answered with start');
|
|
922
|
+
assert.strictEqual(start[1].jsep.sdp, 'answer-2');
|
|
923
|
+
assert.strictEqual(sub.orientationId, 5);
|
|
924
|
+
assert.deepStrictEqual([...sub.receiving], ['0', '1']);
|
|
925
|
+
await publishers([{ type: 'audio', mid: '0' }, { type: 'video', mid: '1' }]);
|
|
926
|
+
assert.strictEqual(updates().length, 1, 'a video already coming in is not asked for again');
|
|
927
|
+
|
|
928
|
+
// Its video track starts stills, which learn the orientation id.
|
|
929
|
+
let rtp = null;
|
|
930
|
+
feedPc.trackListeners.forEach((fn) => fn({ kind: 'video', ssrc: 77, onReceiveRtp: { subscribe: (fn2) => { rtp = fn2; } } }));
|
|
931
|
+
assert.ok(sub.camera, 'stills are set up');
|
|
932
|
+
assert.strictEqual(sub.camera.orientationId, 5);
|
|
933
|
+
assert.strictEqual(typeof rtp, 'function', 'video packets go to the stills');
|
|
934
|
+
|
|
935
|
+
// A re-offer the connection cannot take (11 Oct 17:44, werift:
|
|
936
|
+
// "Transceiver with mid=1 not found"): the feed is joined again, once, so
|
|
937
|
+
// a fresh offer brings audio and camera; the caller is never dropped.
|
|
938
|
+
const detached = [];
|
|
939
|
+
session.janus.detach = async (handle) => { detached.push(handle); };
|
|
940
|
+
const joins = () => session.janus.calls.filter((call) => call.request === 'join' && call.ptype === 'subscriber' && call.feed === 43);
|
|
941
|
+
feedPc.failNext = true;
|
|
942
|
+
await listener({ janus: 'event', plugindata: { data: { videoroom: 'updated' } }, jsep: { type: 'offer', sdp: 'm=video 0' } });
|
|
943
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
944
|
+
assert.deepStrictEqual(detached, [sub.handle], 'the old feed connection is let go');
|
|
945
|
+
assert.strictEqual(joins().length, 2, 'the feed is joined again');
|
|
946
|
+
const fresh = session.subscribers.get(43);
|
|
947
|
+
assert.ok(fresh && fresh !== sub && fresh.started && fresh.rejoined, 'a fresh feed connection, started');
|
|
948
|
+
assert.strictEqual(fresh.display, 'caller@calling.invalid');
|
|
949
|
+
// Failing again: no loop of joins.
|
|
950
|
+
fresh.pc.failNext = true;
|
|
951
|
+
await session.janus.listeners.get(fresh.handle)({ janus: 'event', plugindata: { data: { videoroom: 'updated' } }, jsep: { type: 'offer', sdp: 'm=video 0\r\na=mid:1' } });
|
|
952
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
953
|
+
assert.strictEqual(joins().length, 2, 'joined again once only');
|
|
954
|
+
assert.strictEqual(session.subscribers.get(43), fresh, 'and the caller stays');
|
|
955
|
+
} finally { await session.close(); }
|
|
956
|
+
}
|
|
957
|
+
|
|
628
958
|
async function testTrustedPublisherMetadata() {
|
|
629
959
|
const botId = 'b'.repeat(24), userId = 'a'.repeat(24);
|
|
630
960
|
const metadata = (principalKind, id = userId) => ({ kazee: { version: 1, userId: id, principalKind } });
|
|
@@ -745,7 +1075,9 @@ async function testAssignedRelayTransportLifetime() {
|
|
|
745
1075
|
await testSpeakSerialization();
|
|
746
1076
|
await testSpeakCancellation();
|
|
747
1077
|
await testMeasuredMediaReady();
|
|
1078
|
+
testRealWords();
|
|
748
1079
|
await testEchoGateAndBargeIn();
|
|
1080
|
+
await testWordsUnderSpeechDropped();
|
|
749
1081
|
await testPrebufferAndCommit();
|
|
750
1082
|
await testHoldForWords();
|
|
751
1083
|
await testHoldTimeoutDropsNoiseTurn();
|
|
@@ -753,8 +1085,10 @@ async function testAssignedRelayTransportLifetime() {
|
|
|
753
1085
|
await testInterimFallbackAndLateFinal();
|
|
754
1086
|
await testNoiseAfterFallbackCannotReplayPreviousUtterance();
|
|
755
1087
|
await testVersionedFinalsBelongToTheirCommittedSegment();
|
|
1088
|
+
await testEarlyWordsAtCommit();
|
|
756
1089
|
await testTrickleBuffering();
|
|
757
1090
|
await testSemanticPauseResumesWithoutSplittingAudio();
|
|
1091
|
+
await testVersionedWordsNotSound();
|
|
758
1092
|
await testTurnModelFailureAndCloseAreBounded();
|
|
759
1093
|
await testCancelStopsPendingTtsRequest();
|
|
760
1094
|
await testTranscriptAttribution();
|
|
@@ -764,9 +1098,12 @@ async function testAssignedRelayTransportLifetime() {
|
|
|
764
1098
|
await testJoinRetriesNoSuchRoom();
|
|
765
1099
|
await testJoinGivesUpAfterRetries();
|
|
766
1100
|
await testCallerModeRoomLifecycle();
|
|
1101
|
+
await testPreparedGreetingLine();
|
|
767
1102
|
await testCallerModeRoomExistsAndFailure();
|
|
768
1103
|
await testThinkingCues();
|
|
769
1104
|
await testCloseResolvesSpeech();
|
|
770
1105
|
await testDelayedSpeechProtocolReady();
|
|
1106
|
+
await testCallerCamera();
|
|
1107
|
+
await testHeardSummary();
|
|
771
1108
|
console.log("kazee call session: ok");
|
|
772
1109
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
|
@@ -126,6 +126,14 @@ async function immutableSuccessProbe() {
|
|
|
126
126
|
assert.strictEqual(contexts[1].maxTurns, 1);
|
|
127
127
|
assert.strictEqual(contexts[1].timeoutMs, contexts[0].timeoutMs);
|
|
128
128
|
assert.strictEqual(contexts[0].purpose, "compact-summary");
|
|
129
|
+
// The summary stage reads the live conversation through a fork, so its
|
|
130
|
+
// "Summarize this conversation…" prompt is never written into the live
|
|
131
|
+
// session. On 12 Oct a cold-resume summary failed (OAuth refresh error) and
|
|
132
|
+
// left that prompt behind; the next turn answered it and the whole brief,
|
|
133
|
+
// seed and all, went to the chat as a reply.
|
|
134
|
+
assert.strictEqual(contexts[0].resumeSessionId, "old-claude");
|
|
135
|
+
assert.strictEqual(contexts[0].forkSession, true, "the summary stage forks the live session instead of writing into it");
|
|
136
|
+
assert.strictEqual(contexts[1].forkSession, false, "the seed starts a fresh session: nothing to fork");
|
|
129
137
|
assert.strictEqual(contexts[0].recallQuery, null, "summary stage carries no recall query");
|
|
130
138
|
// Claude runs the summary stage with --max-turns 1: a tool call (a transcript
|
|
131
139
|
// search, a status check) uses up the one turn and ends it with no brief, so
|
|
@@ -610,15 +610,25 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
610
610
|
// app's address bar, agent-browser's settings for the agent, and container mode for Agent
|
|
611
611
|
// Space's browser container. A canary build for the Browser canary bot; the fleet waits
|
|
612
612
|
// for his OK.
|
|
613
|
-
// 3.5.
|
|
614
|
-
// the
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
// 3.5.
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
|
|
613
|
+
// 3.5.27 approved under Sumeet's 2026-10-11 08:24 EAT go-ahead ("Yeah, let's give it a
|
|
614
|
+
// shot") to build the calls' quick voice and put it live with the speak-first guidance in
|
|
615
|
+
// one restart: a canary build for his own bot; it builds on 3.5.24, without the Browser
|
|
616
|
+
// canaries 3.5.25 and 3.5.26.
|
|
617
|
+
// 3.5.37 under Sumeet's 2026-10-11 15:13 EAT call ("call me once you have the features ready"): camera stills on calls, after
|
|
618
|
+
// 3.5.36 (same call: hang up at once, carry the work on in the chat), 3.5.35 (14:59 hang-up tags) and 3.5.34 (14:17 "do the
|
|
619
|
+
// call's suggestions and tests"): local builds for his own bot; the fleet waits for his OK.
|
|
620
|
+
// 3.5.32 under Sumeet's 2026-10-11 10:51 EAT question (the call went silent after /voice Expresso: voice fallback), after
|
|
621
|
+
// 3.5.31 under Sumeet's 2026-10-11 10:14 EAT call note ("maybe we can get the main bot to look at that next": lead-ins), after
|
|
622
|
+
// 3.5.30 under Sumeet's 2026-10-11 09:50 EAT "Let's work on it end to end and test" (the quick voice runs the call), after
|
|
623
|
+
// 3.5.29 under Sumeet's 2026-10-11 09:26 EAT "Just be calling me when you are ready to test" (calls I place open from the chat too), after
|
|
624
|
+
// 3.5.28 under Sumeet's 2026-10-11 09:00 EAT request for a greeting that picks up where
|
|
625
|
+
// the chat left off, written while the call rings: a local build for his own bot (the
|
|
626
|
+
// restart asked for separately); the fleet waits for his OK.
|
|
627
|
+
// 3.5.58 under Sumeet's 2026-10-13 11:53 EAT "Yes" to rolling the call work and thread calls
|
|
628
|
+
// out to the fleet, canary pod first (his 11:3x EAT "can we roll it out the rest of the fleet?").
|
|
629
|
+
// 3.5.59 under Sumeet's 2026-10-13 16:39 EAT "fix the other bug also but approvals need to go to
|
|
630
|
+
// the owners direct chat" (kazee-tickets-bot's Ops room): canary on that bot first.
|
|
631
|
+
assert.strictEqual(pkg.version, "3.5.59", "release version must remain unchanged without explicit approval");
|
|
622
632
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
623
633
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
624
634
|
}
|
|
@@ -91,6 +91,7 @@ if (process.argv[2] === "--probe") {
|
|
|
91
91
|
assert.ok(observed.voiceBundle.dynamicContext.includes("LIVE VOICE CALL"));
|
|
92
92
|
assert.ok(observed.voiceBundle.dynamicContext.includes("I heard a beep before you answered."));
|
|
93
93
|
assert.ok(observed.voiceBundle.dynamicContext.includes("Do not claim an earlier request was acted on without tool evidence."));
|
|
94
|
+
assert.ok(observed.voiceBundle.dynamicContext.includes("your FIRST action is a call-say"), "a live call speaks a short holding line before it works");
|
|
94
95
|
assert.strictEqual(observed.voiceBundle.userPrompt, "Explain that beep.");
|
|
95
96
|
assert.notStrictEqual(
|
|
96
97
|
`${observed.bundle.dynamicContext}\n\n${observed.bundle.userPrompt}`,
|
package/test-provider-runner.js
CHANGED
|
@@ -142,6 +142,8 @@ async function main() {
|
|
|
142
142
|
assert.strictEqual(voiceContext.fresh, true);
|
|
143
143
|
assert.strictEqual(voiceContext.attachSession, false, "call completion must not replace the coding session");
|
|
144
144
|
assert.strictEqual(voiceContext.skipAutoCompact, true, "call cannot compact the room's coding session");
|
|
145
|
+
assert.ok(voiceContext.laneKey.endsWith("#call"), "a call turn runs in its own lane, not behind the chat's");
|
|
146
|
+
assert.strictEqual(voiceContext.threadId, null);
|
|
145
147
|
voiceStore.adapter.callHistory[0].text = "later mutation";
|
|
146
148
|
assert.strictEqual(voiceContext.callHistory[0].text, "What about the beeping?", "queued history is immutable");
|
|
147
149
|
assert.ok(Object.isFrozen(voiceContext.callHistory[0]));
|
package/test-run-lock.js
CHANGED
|
@@ -180,4 +180,28 @@ releaseAll();
|
|
|
180
180
|
assert.ok(long.includes(`"${"x".repeat(59)}…"`), "a long label is shortened to 60 characters");
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// A live call's turns have a lane of their own beside their chat's, and never
|
|
184
|
+
// wait for a slot: 11 Oct, a long turn in the main chat held every call turn
|
|
185
|
+
// back, and only the quick voice spoke on the call.
|
|
186
|
+
{
|
|
187
|
+
const { callLaneKey, isCallLaneKey, parseConversationKey } = require("./core/identity");
|
|
188
|
+
const CALL = callLaneKey(KAZEE);
|
|
189
|
+
assert.strictEqual(CALL, `${KAZEE}#call`);
|
|
190
|
+
assert.strictEqual(callLaneKey(CALL), CALL, "idempotent");
|
|
191
|
+
assert.ok(isCallLaneKey(CALL) && !isCallLaneKey(KAZEE));
|
|
192
|
+
assert.deepStrictEqual(parseConversationKey(CALL), parseConversationKey(KAZEE), "same chat, no thread");
|
|
193
|
+
assert.strictEqual(parseConversationKey(callLaneKey(THREAD)).threadId, "aaaaaaaaaaaaaaaaaaaaaaaa");
|
|
194
|
+
|
|
195
|
+
const s = fresh();
|
|
196
|
+
assert.ok(acquireRunLock(s, KAZEE), "a long turn runs in the chat");
|
|
197
|
+
assert.strictEqual(admitLane(s, KAZEE).reason, "busy");
|
|
198
|
+
assert.ok(admitLane(s, CALL).ok, "the call's turn starts beside it");
|
|
199
|
+
assert.strictEqual(admitLane(s, CALL).reason, "busy", "one call turn at a time in the call lane");
|
|
200
|
+
laneFor(s, CALL).preparingRun = false;
|
|
201
|
+
for (let i = 0; i < 6; i += 1) acquireRunLock(s, `kazee:busy-${i}`);
|
|
202
|
+
assert.strictEqual(admitLane(s, "kazee:another").reason, "cap", "a chat waits for a slot");
|
|
203
|
+
assert.ok(admitLane(s, CALL).ok, "a call never waits for a slot");
|
|
204
|
+
releaseAll();
|
|
205
|
+
}
|
|
206
|
+
|
|
183
207
|
console.log("run-lock OK");
|