@vellumai/assistant 0.12.2-dev.202609181615.645c0a5 → 0.12.2-dev.202609181713.fb1ff61

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/ARCHITECTURE.md CHANGED
@@ -614,6 +614,8 @@ Transcription mode is selected once per session in `media-stream-stt-session.ts`
614
614
 
615
615
  Every phone turn runs the same two-leg triage as live voice through `startVoiceTurn` (`src/calls/voice-session-bridge.ts`): `call-controller.ts` opens on a toolless front-door leg (`routingLeg: "front-door"`, the `voiceFrontDoor` call site) and drives it through the shared `createFrontDoorLegCoordinator` (`src/calls/voice-leg-coordinator.ts`), which reads the stream through the verdict machine and sequences the hand-off (pause narration, abort the leg, resolve and speak the bridge, mark it as the floor holder, start the escalated leg pinned to the conversation's own model, re-arm narration); each driver supplies only a host for how text and the bridge are spoken, how a leg is started or aborted, and (live voice only) the speculative hold and commit. Phone has no partial transcripts, so the hold verdict is never taught and routing is escalate-only. The controller also passes the bridge's turn callbacks (tool activity is recorded as `tool_use_started` / `tool_use_completed` call events, persisted row ids ride the `assistant_spoke` event), `launchedAtMs` for dispatch timing, and a `voiceTelemetry` bag keyed by the call session with a `phone_inbound` / `phone_outbound` entry. Both drivers share the spoken progress narration cadence (`src/calls/voice-progress-cadence.ts`, tuned by `voice.frontModel.progress`): the cadence owns the tool-activity log, the triggers (an ops burst, a long operation completing, a full interval of audible silence with news, the `maxSilenceMs` heartbeat) and the generated or static phrase, while each driver supplies its own view of audible silence (live voice from its TTS queue and playback-tail estimate; the media-stream transport from `isPlaybackIdle()` and a running sum of sent frame durations) and how to speak a phrase.
616
616
 
617
+ Both drivers also share the per-turn latency marks (`src/calls/voice-metrics.ts`). The controller opens a turn's marks when it dispatches, seeded with when the caller's transcript arrived, and stamps the leg dispatch, the first assistant delta, the first audible audio, a barge-in, and how the turn settled. Live voice streams a snapshot to its client on every mark; a call has no client on the line, so the aggregate is logged once per turn, cancelled turns included. Each call also records a `phone_call_started` / `phone_call_ended` pair on the onboarding telemetry substrate (`src/telemetry/phone-call-funnel.ts`), written by the call store at session creation and at the single terminal status transition: duration is the gap between the two rows, and a call that took no caller turn carries why on the end row (`silent_no_connect` when it never connected, `silent_no_turn` when it did and nobody spoke).
618
+
617
619
  A credential preflight (`resolveTelephonyCredentialReadiness()` in `src/calls/telephony-credential-preflight.ts`) gates every call: it requires a credentialed, telephony-capable STT provider **and** a media-stream-playable TTS provider (the configured one or a credentialed playable fallback). Inbound calls that fail the preflight receive `<Say>` setup-required copy plus `<Hangup/>` instead of a doomed stream; outbound placement fails before dialing via `preflightVoiceIngress()` with the same user-facing message.
618
620
 
619
621
  Key modules:
@@ -623,6 +625,7 @@ Key modules:
623
625
  | `src/calls/twilio-routes.ts` | Voice webhook handler; generates `<Connect><Stream>` TwiML, enforces the inbound credential preflight |
624
626
  | `src/calls/telephony-credential-preflight.ts` | Combined STT + TTS credential-readiness resolver |
625
627
  | `src/calls/media-stream-parser.ts` | Twilio Media Streams protocol parser |
628
+ | `src/calls/voice-metrics.ts` | Per-turn latency marks, shared with live voice |
626
629
  | `src/calls/media-turn-detector.ts` | Energy-based VAD turn detector for raw audio (batch mode) |
627
630
  | `src/calls/media-stream-stt-session.ts` | STT session — streaming/batch mode selection and transcription via `services.stt` |
628
631
  | `src/calls/media-stream-audio-transcode.ts` | Mu-law ↔ PCM16 codecs and resampling |
@@ -729,7 +732,7 @@ The assistant-side live voice module is intentionally bounded under `src/live-vo
729
732
  | `live-voice-session.ts` | Session orchestration: streaming STT, push-to-talk release, voice turn bridge callbacks, assistant text deltas, TTS, archive, metrics, interrupt, and close |
730
733
  | `live-voice-tts.ts` | Streaming TTS helper that resolves `services.tts`, requires `TtsProvider.synthesizeStream()`, and forwards audio chunks as `tts_audio` frames |
731
734
  | `live-voice-archive.ts` | Audio artifact creation/linking for user utterance and assistant response message IDs |
732
- | `live-voice-metrics.ts` | Per-session and per-turn latency snapshots emitted as `metrics` frames |
735
+ | `../calls/voice-metrics.ts` | Shared per-session and per-turn latency marks; live voice emits each snapshot as a `metrics` frame, phone calls log the per-turn aggregate |
733
736
 
734
737
  Live voice STT uses the same `resolveStreamingTranscriber()` path as conversation streaming, dialed with the provider the `liveVoice` STT role resolves to after managed-speech defaulting (`resolveEffectiveSpeechProviders`, see `config/managed-speech-defaults.ts`). For V1 latency-sensitive behavior, that provider must resolve to a `daemon-streaming` transcriber whose catalog entry has `conversationStreamingMode: "realtime-ws"` and usable credentials. Providers that only support batch or incremental-batch transcription remain valid for other voice surfaces, but do not satisfy live voice's streaming STT requirement.
735
738
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609181615.645c0a5",
3
+ "version": "0.12.2-dev.202609181713.fb1ff61",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -102,6 +102,7 @@ describe("call-conversation-messages", () => {
102
102
  const conversationId = "conv-call-msg-failed";
103
103
  ensureConversation(conversationId);
104
104
  const session = createCallSession({
105
+ direction: "inbound",
105
106
  conversationId,
106
107
  provider: "twilio",
107
108
  fromNumber: "+15550001111",
@@ -122,6 +123,7 @@ describe("call-conversation-messages", () => {
122
123
  const conversationId = "conv-call-msg-cancelled";
123
124
  ensureConversation(conversationId);
124
125
  const session = createCallSession({
126
+ direction: "inbound",
125
127
  conversationId,
126
128
  provider: "twilio",
127
129
  fromNumber: "+15550001111",
@@ -142,6 +144,7 @@ describe("call-conversation-messages", () => {
142
144
  const conversationId = "conv-call-msg-completed";
143
145
  ensureConversation(conversationId);
144
146
  const session = createCallSession({
147
+ direction: "inbound",
145
148
  conversationId,
146
149
  provider: "twilio",
147
150
  fromNumber: "+15550001111",
@@ -171,6 +174,7 @@ describe("call-conversation-messages", () => {
171
174
  const conversationId = "conv-call-msg-fallback";
172
175
  ensureConversation(conversationId);
173
176
  const session = createCallSession({
177
+ direction: "inbound",
174
178
  conversationId,
175
179
  provider: "twilio",
176
180
  fromNumber: "+12025550101",
@@ -208,6 +212,7 @@ describe("call-conversation-messages", () => {
208
212
  const conversationId = "conv-call-msg-schema";
209
213
  ensureConversation(conversationId);
210
214
  const session = createCallSession({
215
+ direction: "inbound",
211
216
  conversationId,
212
217
  provider: "twilio",
213
218
  fromNumber: "+12025550101",
@@ -54,9 +54,14 @@ function resetTables() {
54
54
  ensuredConvIds = new Set();
55
55
  }
56
56
 
57
- function createTestCallSession(opts: Parameters<typeof createCallSession>[0]) {
57
+ function createTestCallSession(
58
+ opts: Omit<Parameters<typeof createCallSession>[0], "direction"> & {
59
+ direction?: Parameters<typeof createCallSession>[0]["direction"];
60
+ },
61
+ ) {
58
62
  ensureConversation(opts.conversationId);
59
- return createCallSession(opts);
63
+ // Direction only shapes the funnel stamp; these suites test other things.
64
+ return createCallSession({ direction: "inbound", ...opts });
60
65
  }
61
66
 
62
67
  /** Backdate a session's createdAt so it appears older than the grace period. */
@@ -327,6 +327,7 @@ describe("runtime call routes — HTTP layer", () => {
327
327
  ensureConversation("conv-get-1");
328
328
 
329
329
  const session = createCallSession({
330
+ direction: "outbound",
330
331
  conversationId: "conv-get-1",
331
332
  provider: "twilio",
332
333
  fromNumber: "+15550001111",
@@ -378,6 +379,7 @@ describe("runtime call routes — HTTP layer", () => {
378
379
  ensureConversation("conv-cancel-1");
379
380
 
380
381
  const session = createCallSession({
382
+ direction: "inbound",
381
383
  conversationId: "conv-cancel-1",
382
384
  provider: "twilio",
383
385
  fromNumber: "+15550001111",
@@ -407,6 +409,7 @@ describe("runtime call routes — HTTP layer", () => {
407
409
  ensureConversation("conv-cancel-2");
408
410
 
409
411
  const session = createCallSession({
412
+ direction: "inbound",
410
413
  conversationId: "conv-cancel-2",
411
414
  provider: "twilio",
412
415
  fromNumber: "+15550001111",
@@ -447,6 +450,7 @@ describe("runtime call routes — HTTP layer", () => {
447
450
  ensureConversation("conv-answer-badjson");
448
451
 
449
452
  const session = createCallSession({
453
+ direction: "inbound",
450
454
  conversationId: "conv-answer-badjson",
451
455
  provider: "twilio",
452
456
  fromNumber: "+15550001111",
@@ -469,6 +473,7 @@ describe("runtime call routes — HTTP layer", () => {
469
473
  ensureConversation("conv-answer-1");
470
474
 
471
475
  const session = createCallSession({
476
+ direction: "inbound",
472
477
  conversationId: "conv-answer-1",
473
478
  provider: "twilio",
474
479
  fromNumber: "+15550001111",
@@ -495,6 +500,7 @@ describe("runtime call routes — HTTP layer", () => {
495
500
  ensureConversation("conv-answer-2");
496
501
 
497
502
  const session = createCallSession({
503
+ direction: "inbound",
498
504
  conversationId: "conv-answer-2",
499
505
  provider: "twilio",
500
506
  fromNumber: "+15550001111",
@@ -517,6 +523,7 @@ describe("runtime call routes — HTTP layer", () => {
517
523
  ensureConversation("conv-answer-3");
518
524
 
519
525
  const session = createCallSession({
526
+ direction: "inbound",
520
527
  conversationId: "conv-answer-3",
521
528
  provider: "twilio",
522
529
  fromNumber: "+15550001111",
@@ -548,6 +555,7 @@ describe("runtime call routes — HTTP layer", () => {
548
555
  ensureConversation("conv-instr-badjson");
549
556
 
550
557
  const session = createCallSession({
558
+ direction: "inbound",
551
559
  conversationId: "conv-instr-badjson",
552
560
  provider: "twilio",
553
561
  fromNumber: "+15550001111",
@@ -570,6 +578,7 @@ describe("runtime call routes — HTTP layer", () => {
570
578
  ensureConversation("conv-instr-empty");
571
579
 
572
580
  const session = createCallSession({
581
+ direction: "inbound",
573
582
  conversationId: "conv-instr-empty",
574
583
  provider: "twilio",
575
584
  fromNumber: "+15550001111",
@@ -596,6 +605,7 @@ describe("runtime call routes — HTTP layer", () => {
596
605
  ensureConversation("conv-instr-missing");
597
606
 
598
607
  const session = createCallSession({
608
+ direction: "inbound",
599
609
  conversationId: "conv-instr-missing",
600
610
  provider: "twilio",
601
611
  fromNumber: "+15550001111",
@@ -640,6 +650,7 @@ describe("runtime call routes — HTTP layer", () => {
640
650
  ensureConversation("conv-instr-ended");
641
651
 
642
652
  const session = createCallSession({
653
+ direction: "inbound",
643
654
  conversationId: "conv-instr-ended",
644
655
  provider: "twilio",
645
656
  fromNumber: "+15550001111",
@@ -668,6 +679,7 @@ describe("runtime call routes — HTTP layer", () => {
668
679
  ensureConversation("conv-instr-no-orch");
669
680
 
670
681
  const session = createCallSession({
682
+ direction: "inbound",
671
683
  conversationId: "conv-instr-no-orch",
672
684
  provider: "twilio",
673
685
  fromNumber: "+15550001111",
@@ -53,9 +53,14 @@ function resetTables() {
53
53
  }
54
54
 
55
55
  /** Wrapper that ensures the FK conversation row exists before creating a session. */
56
- function createTestCallSession(opts: Parameters<typeof createCallSession>[0]) {
56
+ function createTestCallSession(
57
+ opts: Omit<Parameters<typeof createCallSession>[0], "direction"> & {
58
+ direction?: Parameters<typeof createCallSession>[0]["direction"];
59
+ },
60
+ ) {
57
61
  ensureConversation(opts.conversationId);
58
- return createCallSession(opts);
62
+ // Direction only shapes the funnel stamp; these suites test other things.
63
+ return createCallSession({ direction: "inbound", ...opts });
59
64
  }
60
65
 
61
66
  describe("call-store", () => {
@@ -153,6 +153,7 @@ describe("Call session mode metadata", () => {
153
153
 
154
154
  const { conversationId } = getOrCreateConversation("test-conv-mode");
155
155
  const session = createCallSession({
156
+ direction: "inbound",
156
157
  conversationId,
157
158
  provider: "twilio",
158
159
  fromNumber: "+15551234567",
@@ -181,6 +182,7 @@ describe("Call session mode metadata", () => {
181
182
  "test-conv-mode-default",
182
183
  );
183
184
  const session = createCallSession({
185
+ direction: "inbound",
184
186
  conversationId,
185
187
  provider: "twilio",
186
188
  fromNumber: "+15551234567",
@@ -158,6 +158,7 @@ describe("guardian-dispatch", () => {
158
158
  ensureConversation(convId);
159
159
 
160
160
  const session = createCallSession({
161
+ direction: "inbound",
161
162
  conversationId: convId,
162
163
  provider: "twilio",
163
164
  fromNumber: "+15550001111",
@@ -209,6 +210,7 @@ describe("guardian-dispatch", () => {
209
210
  ensureConversation(convId);
210
211
 
211
212
  const session = createCallSession({
213
+ direction: "inbound",
212
214
  conversationId: convId,
213
215
  provider: "twilio",
214
216
  fromNumber: "+15550001111",
@@ -238,6 +240,7 @@ describe("guardian-dispatch", () => {
238
240
  ensureConversation(convId);
239
241
 
240
242
  const session = createCallSession({
243
+ direction: "inbound",
241
244
  conversationId: convId,
242
245
  provider: "twilio",
243
246
  fromNumber: "+15550001111",
@@ -282,6 +285,7 @@ describe("guardian-dispatch", () => {
282
285
  };
283
286
 
284
287
  const session = createCallSession({
288
+ direction: "inbound",
285
289
  conversationId: convId,
286
290
  provider: "twilio",
287
291
  fromNumber: "+15550001111",
@@ -324,6 +328,7 @@ describe("guardian-dispatch", () => {
324
328
  };
325
329
 
326
330
  const session = createCallSession({
331
+ direction: "inbound",
327
332
  conversationId: convId,
328
333
  provider: "twilio",
329
334
  fromNumber: "+15550001111",
@@ -369,6 +374,7 @@ describe("guardian-dispatch", () => {
369
374
  };
370
375
 
371
376
  const session = createCallSession({
377
+ direction: "inbound",
372
378
  conversationId: convId,
373
379
  provider: "twilio",
374
380
  fromNumber: "+15550001111",
@@ -396,6 +402,7 @@ describe("guardian-dispatch", () => {
396
402
  ensureConversation(convId);
397
403
 
398
404
  const session = createCallSession({
405
+ direction: "inbound",
399
406
  conversationId: convId,
400
407
  provider: "twilio",
401
408
  fromNumber: "+15550001111",
@@ -431,6 +438,7 @@ describe("guardian-dispatch", () => {
431
438
  ensureConversation(convId);
432
439
 
433
440
  const session = createCallSession({
441
+ direction: "inbound",
434
442
  conversationId: convId,
435
443
  provider: "twilio",
436
444
  fromNumber: "+15550001111",
@@ -456,6 +464,7 @@ describe("guardian-dispatch", () => {
456
464
  ensureConversation(convId);
457
465
 
458
466
  const session = createCallSession({
467
+ direction: "inbound",
459
468
  conversationId: convId,
460
469
  provider: "twilio",
461
470
  fromNumber: "+15550001111",
@@ -488,6 +497,7 @@ describe("guardian-dispatch", () => {
488
497
  const sharedConversationId = "conv-shared-guardian";
489
498
 
490
499
  const session = createCallSession({
500
+ direction: "inbound",
491
501
  conversationId: convId,
492
502
  provider: "twilio",
493
503
  fromNumber: "+15550001111",
@@ -576,6 +586,7 @@ describe("guardian-dispatch", () => {
576
586
  const sharedConversationId = "conv-affinity-guardian";
577
587
 
578
588
  const session = createCallSession({
589
+ direction: "inbound",
579
590
  conversationId: convId,
580
591
  provider: "twilio",
581
592
  fromNumber: "+15550001111",
@@ -648,6 +659,7 @@ describe("guardian-dispatch", () => {
648
659
  const sharedConversationId = "conv-affinity-tool-guardian";
649
660
 
650
661
  const session = createCallSession({
662
+ direction: "inbound",
651
663
  conversationId: convId,
652
664
  provider: "twilio",
653
665
  fromNumber: "+15550001111",
@@ -727,6 +739,7 @@ describe("guardian-dispatch", () => {
727
739
  const sharedConversationId = "conv-affinity-triple";
728
740
 
729
741
  const session = createCallSession({
742
+ direction: "inbound",
730
743
  conversationId: convId,
731
744
  provider: "twilio",
732
745
  fromNumber: "+15550001111",
@@ -160,6 +160,7 @@ describe("ASK_GUARDIAN notification path", () => {
160
160
  ensureConversation(convId);
161
161
 
162
162
  const session = createCallSession({
163
+ direction: "inbound",
163
164
  conversationId: convId,
164
165
  provider: "twilio",
165
166
  fromNumber: "+15550001111",
@@ -220,6 +221,7 @@ describe("ASK_GUARDIAN notification path", () => {
220
221
  };
221
222
 
222
223
  const session = createCallSession({
224
+ direction: "inbound",
223
225
  conversationId: convId,
224
226
  provider: "twilio",
225
227
  fromNumber: "+15550001111",
@@ -263,6 +265,7 @@ describe("ASK_GUARDIAN notification path", () => {
263
265
  };
264
266
 
265
267
  const session = createCallSession({
268
+ direction: "inbound",
266
269
  conversationId: convId,
267
270
  provider: "twilio",
268
271
  fromNumber: "+15550001111",
@@ -308,6 +311,7 @@ describe("ASK_GUARDIAN notification path", () => {
308
311
  };
309
312
 
310
313
  const session = createCallSession({
314
+ direction: "inbound",
311
315
  conversationId: convId,
312
316
  provider: "twilio",
313
317
  fromNumber: "+15550001111",
@@ -338,6 +342,7 @@ describe("ASK_GUARDIAN notification path", () => {
338
342
  ensureConversation(convId);
339
343
 
340
344
  const session = createCallSession({
345
+ direction: "inbound",
341
346
  conversationId: convId,
342
347
  provider: "twilio",
343
348
  fromNumber: "+15550001111",
@@ -370,6 +375,7 @@ describe("ASK_GUARDIAN notification path", () => {
370
375
  const sharedConvId = "conv-guardian-shared-thread";
371
376
 
372
377
  const session = createCallSession({
378
+ direction: "inbound",
373
379
  conversationId: convId,
374
380
  provider: "twilio",
375
381
  fromNumber: "+15550001111",
@@ -463,6 +469,7 @@ describe("ASK_GUARDIAN notification path", () => {
463
469
  };
464
470
 
465
471
  const session = createCallSession({
472
+ direction: "inbound",
466
473
  conversationId: convId,
467
474
  provider: "twilio",
468
475
  fromNumber: "+15550001111",
@@ -387,6 +387,7 @@ function createTestSession(
387
387
  ) {
388
388
  ensureConversation(convId);
389
389
  const session = createCallSession({
390
+ direction: "inbound",
390
391
  conversationId: convId,
391
392
  provider: "twilio",
392
393
  fromNumber: "+14155550111",
@@ -421,6 +421,7 @@ function setupController(
421
421
  ) {
422
422
  ensureConversation("conv-ctrl-test");
423
423
  const session = createCallSession({
424
+ direction: "inbound",
424
425
  conversationId: "conv-ctrl-test",
425
426
  provider: "twilio",
426
427
  fromNumber: "+15551111111",
@@ -471,6 +472,7 @@ function setupControllerWithOrigin(task?: string) {
471
472
  ensureConversation("conv-ctrl-voice");
472
473
  ensureConversation("conv-ctrl-origin");
473
474
  const session = createCallSession({
475
+ direction: "inbound",
474
476
  conversationId: "conv-ctrl-voice",
475
477
  provider: "twilio",
476
478
  fromNumber: "+15551111111",
@@ -5028,4 +5030,96 @@ describe("call-controller", () => {
5028
5030
  await turnPromise.catch(() => {});
5029
5031
  });
5030
5032
  });
5033
+ describe("turn metrics", () => {
5034
+ test("a caller turn records the marks the latency numbers are built from", async () => {
5035
+ const { controller } = setupController();
5036
+
5037
+ await controller.handleCallerUtterance("What is the weather?");
5038
+
5039
+ const snapshot = controller.getMetricsSnapshot();
5040
+ expect(snapshot.recentTurns.length).toBe(1);
5041
+ const turn = snapshot.recentTurns[0];
5042
+ expect(turn.status).toBe("completed");
5043
+ // The transcript mark is seeded from when the caller stopped talking,
5044
+ // the dispatch mark from when the leg went out, and the delta mark from
5045
+ // the first token back: the three anchors every phone latency number is
5046
+ // subtracted from.
5047
+ expect(turn.timestamps.finalTranscriptAtMs).not.toBeNull();
5048
+ expect(turn.timestamps.assistantDispatchAtMs).not.toBeNull();
5049
+ expect(turn.timestamps.firstAssistantDeltaAtMs).not.toBeNull();
5050
+ expect(turn.durations.dispatchToFirstAssistantDeltaMs).not.toBeNull();
5051
+ expect(turn.durations.totalTurnDurationMs).not.toBeNull();
5052
+ // The provider's final doubles as the speech-end anchor, so the turn's
5053
+ // round trip is measurable on phone as it is in live voice.
5054
+ expect(turn.timestamps.utteranceEndAtMs).not.toBeNull();
5055
+ expect(turn.durations.roundTripMs).not.toBeNull();
5056
+
5057
+ controller.destroy();
5058
+ });
5059
+
5060
+ test("the transcript mark predates the dispatch it triggered", async () => {
5061
+ // Seeding backdates the turn to the caller's own boundary; a turn that
5062
+ // opened at dispatch time would report a felt latency that starts after
5063
+ // the caller already finished speaking.
5064
+ const { controller } = setupController();
5065
+
5066
+ await controller.handleCallerUtterance("Hello there");
5067
+
5068
+ const turn = controller.getMetricsSnapshot().recentTurns[0];
5069
+ const finalTranscriptAtMs = turn.timestamps.finalTranscriptAtMs;
5070
+ const assistantDispatchAtMs = turn.timestamps.assistantDispatchAtMs;
5071
+ expect(finalTranscriptAtMs).not.toBeNull();
5072
+ expect(assistantDispatchAtMs).not.toBeNull();
5073
+ expect(finalTranscriptAtMs!).toBeLessThanOrEqual(assistantDispatchAtMs!);
5074
+ expect(turn.timestamps.startedAtMs).toBeLessThanOrEqual(
5075
+ finalTranscriptAtMs!,
5076
+ );
5077
+
5078
+ controller.destroy();
5079
+ });
5080
+
5081
+ test("a barge-in ends the turn once, as cancelled", async () => {
5082
+ // One interruption is one cancelled turn: the count downstream reads as
5083
+ // "turns the caller cut off", so a second settle would double it.
5084
+ // A turn that never emits or completes holds the controller in the
5085
+ // pre-speech phase, which a sustained barge-in can still cut off.
5086
+ mockStartVoiceTurn.mockImplementation(
5087
+ async (opts: { onComplete: () => void }) => ({
5088
+ turnId: "run-metrics-slow",
5089
+ abort: () => opts.onComplete(),
5090
+ }),
5091
+ );
5092
+ const { controller } = setupController();
5093
+ const turnPromise = controller.handleCallerUtterance("Tell me a story");
5094
+ for (let i = 0; i < 5; i++) {
5095
+ await Promise.resolve();
5096
+ }
5097
+ expect(controller.getState()).toBe("processing");
5098
+
5099
+ expect(controller.handleBargeIn()).toBe(true);
5100
+ await turnPromise.catch(() => {});
5101
+ controller.destroy();
5102
+
5103
+ const turns = controller.getMetricsSnapshot().recentTurns;
5104
+ expect(turns.length).toBe(1);
5105
+ expect(turns[0].status).toBe("cancelled");
5106
+ expect(turns[0].cancellationReason).toBe("interrupted");
5107
+ expect(turns[0].timestamps.bargeInAtMs).not.toBeNull();
5108
+ });
5109
+
5110
+ test("no turn is invented between turns", async () => {
5111
+ // Every mark opens a turn when none is active, so a barge-in on an idle
5112
+ // controller must leave the collector empty rather than start a turn
5113
+ // that never settles.
5114
+ const { controller } = setupController();
5115
+
5116
+ expect(controller.handleBargeIn()).toBe(false);
5117
+
5118
+ const snapshot = controller.getMetricsSnapshot();
5119
+ expect(snapshot.recentTurns.length).toBe(0);
5120
+ expect(snapshot.activeTurn).toBeNull();
5121
+
5122
+ controller.destroy();
5123
+ });
5124
+ });
5031
5125
  });