@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +70 -10
  2. package/bin/cli.js +10 -3
  3. package/bot.js +11 -0
  4. package/channels/kazee/adapter.js +244 -23
  5. package/channels/kazee/call/camera.js +233 -0
  6. package/channels/kazee/call/job-steps.js +81 -0
  7. package/channels/kazee/call/manager.js +1056 -27
  8. package/channels/kazee/call/miss-capture.js +82 -0
  9. package/channels/kazee/call/quick-voice.js +610 -0
  10. package/channels/kazee/call/session.js +787 -36
  11. package/channels/kazee/call/thread-context.js +100 -0
  12. package/channels/kazee/catch-up.js +275 -0
  13. package/core/actions.js +13 -4
  14. package/core/enforcer.js +3 -0
  15. package/core/handlers.js +14 -16
  16. package/core/identity.js +15 -1
  17. package/core/loopback.js +4 -1
  18. package/core/media.js +39 -1
  19. package/core/relationship.js +41 -18
  20. package/core/run-context.js +27 -3
  21. package/core/runner.js +70 -1
  22. package/core/state.js +6 -2
  23. package/core/switchboard/brain-link.js +161 -0
  24. package/core/switchboard/call-host.js +298 -0
  25. package/core/switchboard/handoff.js +80 -0
  26. package/core/switchboard/ipc.js +130 -0
  27. package/core/switchboard/supervisor.js +225 -0
  28. package/core/system-prompt.js +64 -12
  29. package/core/transcripts.js +39 -0
  30. package/core/tts-voice.js +62 -4
  31. package/core/turn-journal.js +23 -1
  32. package/core/utility-agent.js +5 -2
  33. package/package.json +2 -2
  34. package/test-call-speech.js +7 -0
  35. package/test-external-rule-scopes.js +24 -0
  36. package/test-fixtures/switchboard-fake-brain.js +20 -0
  37. package/test-kazee-call-manager.js +22 -4
  38. package/test-kazee-call-session.js +345 -8
  39. package/test-provider-compaction.js +8 -0
  40. package/test-provider-language.js +19 -9
  41. package/test-provider-prompt-parity.js +1 -0
  42. package/test-provider-runner.js +2 -0
  43. 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 playing = session.speak("long answer");
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
- for (let i = 0; i < 3; i += 1) session._onFeedRtp(101, { payload: toneOpus(gen, 12000) }); // ~-12dB: real speech
180
- assert.strictEqual(await playing, false, "barge-in cancels TTS");
181
- assert(sub.stt.audioFrames.length >= 3, "barge-in speech reaches STT");
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.strictEqual(session._thinkTimer, null, "caller speech stops the thinking loop");
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.33 approved by Sumeet 2026-10-11 09:16 EAT ("Yes" to: switch off the Heard echo,
614
- // the notes lines and the progress message by default, show typing, send one message,
615
- // roll it to every bot): quiet chats by default, /heard /notes /progress to opt in.
616
- // Built on 3.5.24 for the fleet; 3.5.25-3.5.32 (Browser and call canaries) are not in it.
617
- // 3.5.47 approved by Sumeet 2026-10-11 21:10 EAT ("proceed to fix", after "Shall I ship the
618
- // fix to the network bot first, tonight?"): the guard judge's floor keeps credentials
619
- // absolute but lets channel rules and mandates allow infrastructure and customer data.
620
- // Built on 3.5.33 (the fleet's version); a canary for the network bot first.
621
- assert.strictEqual(pkg.version, "3.5.47", "release version must remain unchanged without explicit approval");
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}`,
@@ -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");