@vellumai/assistant 0.12.6-staging.3 → 0.12.7-staging.1

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 (97) hide show
  1. package/.env.example +2 -0
  2. package/ARCHITECTURE.md +14 -14
  3. package/docs/architecture/integrations.md +5 -5
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  8. package/node_modules/@vellumai/gateway-client/src/__tests__/route-trust-class.test.ts +111 -0
  9. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -2
  10. package/node_modules/@vellumai/gateway-client/src/index.ts +9 -0
  11. package/node_modules/@vellumai/gateway-client/src/route-trust-class.ts +94 -0
  12. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  13. package/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  14. package/openapi.yaml +153 -5
  15. package/package.json +1 -1
  16. package/src/__tests__/channel-policy.test.ts +7 -0
  17. package/src/__tests__/conversation-routes-disk-view.test.ts +141 -2
  18. package/src/__tests__/identity-routes.test.ts +1 -0
  19. package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
  20. package/src/__tests__/openai-provider.test.ts +27 -0
  21. package/src/__tests__/skill-execute-input.test.ts +11 -0
  22. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  23. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
  24. package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
  25. package/src/agent/loop.ts +133 -12
  26. package/src/calls/__tests__/call-controller.test.ts +273 -1
  27. package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
  28. package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
  29. package/src/calls/call-constants.ts +10 -0
  30. package/src/calls/call-controller.ts +359 -25
  31. package/src/calls/media-stream-server.ts +18 -0
  32. package/src/calls/media-stream-stt-session.ts +30 -9
  33. package/src/channels/config.ts +8 -0
  34. package/src/cli/lib/bundled-marketplace.json +39 -0
  35. package/src/config/composer-preferences.test.ts +97 -0
  36. package/src/config/composer-preferences.ts +95 -0
  37. package/src/config/feature-flag-registry.json +16 -8
  38. package/src/config/schemas/llm.ts +1 -0
  39. package/src/contacts/types.ts +1 -0
  40. package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
  41. package/src/daemon/conversation-agent-loop.ts +3 -0
  42. package/src/daemon/conversation-messaging.ts +4 -0
  43. package/src/daemon/conversation-process.ts +15 -0
  44. package/src/daemon/conversation-queue-manager.ts +2 -0
  45. package/src/daemon/conversation.ts +2 -0
  46. package/src/daemon/handlers/shared.ts +13 -2
  47. package/src/daemon/message-types/sync.ts +1 -0
  48. package/src/daemon/process-message.ts +5 -0
  49. package/src/i18n/__tests__/i18n.test.ts +25 -0
  50. package/src/i18n/assistant-text.ts +18 -0
  51. package/src/i18n/index.ts +1 -0
  52. package/src/i18n/messages.ts +11 -0
  53. package/src/ipc/routes/__tests__/route-adapter.test.ts +40 -0
  54. package/src/ipc/routes/route-adapter.ts +9 -0
  55. package/src/notifications/README.md +1 -1
  56. package/src/notifications/signal.ts +4 -0
  57. package/src/persistence/conversation-key-store.ts +12 -11
  58. package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
  59. package/src/providers/inference/adapter-factory.ts +6 -0
  60. package/src/providers/ionet/client.test.ts +143 -0
  61. package/src/providers/ionet/client.ts +37 -0
  62. package/src/providers/model-catalog.ts +40 -0
  63. package/src/providers/openai/chat-completions-provider.ts +33 -2
  64. package/src/providers/retry.ts +10 -4
  65. package/src/providers/unparseable-tool-args.ts +29 -5
  66. package/src/runtime/__tests__/http-router-trust.test.ts +360 -0
  67. package/src/runtime/__tests__/shared-principal-lookup.test.ts +346 -0
  68. package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -1
  69. package/src/runtime/auth/__tests__/route-policy.test.ts +85 -1
  70. package/src/runtime/auth/__tests__/scopes.test.ts +36 -0
  71. package/src/runtime/auth/route-policy.ts +44 -0
  72. package/src/runtime/auth/scopes.ts +15 -0
  73. package/src/runtime/auth/types.ts +1 -0
  74. package/src/runtime/channel-reply-delivery.ts +2 -0
  75. package/src/runtime/http-router.ts +61 -1
  76. package/src/runtime/http-types.ts +3 -0
  77. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
  78. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
  79. package/src/runtime/migrations/vbundle-builder.ts +19 -3
  80. package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
  81. package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
  82. package/src/runtime/migrations/vbundle-validator.ts +54 -1
  83. package/src/runtime/routes/composer-settings-routes.test.ts +71 -0
  84. package/src/runtime/routes/composer-settings-routes.ts +76 -0
  85. package/src/runtime/routes/conversation-routes.ts +54 -14
  86. package/src/runtime/routes/identity-routes.ts +2 -0
  87. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  88. package/src/runtime/routes/index.ts +2 -0
  89. package/src/runtime/routes/migration-routes.ts +3 -1
  90. package/src/runtime/routes/types.ts +4 -1
  91. package/src/runtime/shared-principal-lookup.ts +235 -0
  92. package/src/tools/host-terminal/host-shell.ts +4 -2
  93. package/src/tools/shared/shell-output.test.ts +42 -0
  94. package/src/tools/shared/shell-output.ts +27 -4
  95. package/src/tools/skills/execute.ts +10 -7
  96. package/src/tools/terminal/shell.ts +2 -0
  97. package/src/tools/tool-approval-handler.ts +9 -1
@@ -225,7 +225,10 @@ import { resetDbForTesting } from "../../__tests__/db-test-helpers.js";
225
225
  import { createGuardianBinding } from "../../__tests__/helpers/create-guardian-binding.js";
226
226
  import { setConfig } from "../../__tests__/helpers/set-config.js";
227
227
  import { loadConfig } from "../../config/loader.js";
228
- import type { VoiceProgressConfig } from "../../config/schemas/voice.js";
228
+ import type {
229
+ VoiceFrontModelConfig,
230
+ VoiceProgressConfig,
231
+ } from "../../config/schemas/voice.js";
229
232
  import { getMessages } from "../../persistence/conversation-crud.js";
230
233
  import { getDb } from "../../persistence/db-connection.js";
231
234
  import { initializeDb } from "../../persistence/db-init.js";
@@ -417,6 +420,8 @@ function setupController(
417
420
  /** Progress narration seam; an explicit null keeps the call silent. */
418
421
  progressNarrator?: VoiceProgressNarrator | null;
419
422
  progressConfig?: VoiceProgressConfig;
423
+ /** Endpointing seam: hold-replay delay and the hold cap. */
424
+ frontModelConfig?: VoiceFrontModelConfig;
420
425
  },
421
426
  ) {
422
427
  ensureConversation("conv-ctrl-test");
@@ -447,6 +452,7 @@ function setupController(
447
452
  progressNarrator:
448
453
  opts?.progressNarrator === undefined ? null : opts.progressNarrator,
449
454
  progressConfig: opts?.progressConfig,
455
+ frontModelConfig: opts?.frontModelConfig,
450
456
  });
451
457
  return { session, relay: transport, controller };
452
458
  }
@@ -511,6 +517,9 @@ describe("call-controller", () => {
511
517
  cfg.services.tts.provider = "elevenlabs";
512
518
  cfg.services.tts.providers["fish-audio"].referenceId = "";
513
519
  cfg.ingress.publicBaseUrl = "https://generic.example.com";
520
+ // Schema default. The hold-replay wait is measured against it, so tests
521
+ // that shrink it must not leak the change into the next one.
522
+ cfg.calls.voice.utteranceEndMs = 1000;
514
523
  mockResolvableProviderKeys = null;
515
524
  // Reset TTS provider registry to ensure clean state
516
525
  registerTestTtsProviders();
@@ -5140,4 +5149,267 @@ describe("call-controller", () => {
5140
5149
  controller.destroy();
5141
5150
  });
5142
5151
  });
5152
+
5153
+ // ── Semantic endpointing (front-door hold verdict) ─────────────────
5154
+
5155
+ describe("semantic endpointing", () => {
5156
+ /** Front-model tuning with a replay short enough for a real timer. */
5157
+ function endpointingConfig(
5158
+ overrides: Partial<VoiceFrontModelConfig> = {},
5159
+ ): VoiceFrontModelConfig {
5160
+ return { ...loadConfig().voice.frontModel, ...overrides };
5161
+ }
5162
+
5163
+ /**
5164
+ * A bridge that holds on every leg whose prompt taught the hold token,
5165
+ * and answers otherwise. Records what each leg was asked and whether it
5166
+ * was rolled back.
5167
+ */
5168
+ function mockHoldingBridge(answer = "Sure, checking Cleveland now.") {
5169
+ const legs: { content: string; unifiedVerdict: boolean }[] = [];
5170
+ const discarded: string[] = [];
5171
+ mockStartVoiceTurn.mockImplementation(
5172
+ async (opts: {
5173
+ content: string;
5174
+ unifiedVerdict?: boolean;
5175
+ onTextDelta: (t: string) => void;
5176
+ onComplete: () => void;
5177
+ }) => {
5178
+ const turnId = `run-${legs.length}`;
5179
+ legs.push({
5180
+ content: opts.content,
5181
+ unifiedVerdict: opts.unifiedVerdict === true,
5182
+ });
5183
+ opts.onTextDelta(opts.unifiedVerdict === true ? "[0]" : answer);
5184
+ opts.onComplete();
5185
+ return {
5186
+ turnId,
5187
+ abort: () => {},
5188
+ discard: async () => {
5189
+ discarded.push(turnId);
5190
+ },
5191
+ };
5192
+ },
5193
+ );
5194
+ return { legs, discarded };
5195
+ }
5196
+
5197
+ test("a hold speaks nothing and carries the words into the next final", async () => {
5198
+ const { legs } = mockHoldingBridge();
5199
+ const { relay, controller } = setupController(undefined, {
5200
+ frontModelConfig: endpointingConfig({ endpointExtensionMs: 10_000 }),
5201
+ });
5202
+
5203
+ // The caller's sentence arrives split across two provider finals.
5204
+ await controller.handleCallerUtterance("Nice. Actually,");
5205
+
5206
+ expect(legs).toHaveLength(1);
5207
+ expect(legs[0].unifiedVerdict).toBe(true);
5208
+ // The hold token is never spoken, and the turn does not end the floor.
5209
+ expect(relay.sentTokens.filter((t) => t.token.length > 0)).toHaveLength(
5210
+ 0,
5211
+ );
5212
+ expect(controller.getState()).toBe("idle");
5213
+
5214
+ await controller.handleCallerUtterance("can you check out Cleveland?");
5215
+
5216
+ expect(legs).toHaveLength(2);
5217
+ expect(legs[1].content).toBe(
5218
+ "Nice. Actually, can you check out Cleveland?",
5219
+ );
5220
+
5221
+ controller.destroy();
5222
+ });
5223
+
5224
+ test("a held leg rolls its persisted user row back", async () => {
5225
+ const { discarded } = mockHoldingBridge();
5226
+ const { controller } = setupController(undefined, {
5227
+ frontModelConfig: endpointingConfig({ endpointExtensionMs: 10_000 }),
5228
+ });
5229
+
5230
+ await controller.handleCallerUtterance("Nice. Actually,");
5231
+
5232
+ expect(discarded).toEqual(["run-0"]);
5233
+
5234
+ controller.destroy();
5235
+ });
5236
+
5237
+ test("only caller speech is dispatched as an endpoint decision", async () => {
5238
+ const { legs } = mockHoldingBridge();
5239
+ const { controller } = setupController(undefined, {
5240
+ frontModelConfig: endpointingConfig({ endpointExtensionMs: 10_000 }),
5241
+ });
5242
+
5243
+ // The opener is the assistant's own turn: it must always speak.
5244
+ await controller.startInitialGreeting();
5245
+
5246
+ expect(legs).toHaveLength(1);
5247
+ expect(legs[0].unifiedVerdict).toBe(false);
5248
+
5249
+ controller.destroy();
5250
+ });
5251
+
5252
+ test("the replay answers a held sentence when the caller says nothing more", async () => {
5253
+ const { legs } = mockHoldingBridge();
5254
+ const { controller } = setupController(undefined, {
5255
+ frontModelConfig: endpointingConfig({ endpointExtensionMs: 20 }),
5256
+ });
5257
+
5258
+ await controller.handleCallerUtterance("Nice. Actually,");
5259
+ expect(legs).toHaveLength(1);
5260
+
5261
+ // No interim words: the provider owes nothing, so the hold extension
5262
+ // is the whole wait.
5263
+ await pollUntil(() => legs.length === 2, 2_000);
5264
+
5265
+ expect(legs[1].content).toBe("Nice. Actually,");
5266
+ // The cap is not yet reached, so the replay may still hold.
5267
+ expect(legs[1].unifiedVerdict).toBe(true);
5268
+
5269
+ controller.destroy();
5270
+ });
5271
+
5272
+ test("the replay waits out the provider's commit window rather than racing the final for the same words", async () => {
5273
+ const { legs } = mockHoldingBridge();
5274
+ // The hold extension elapses long before the provider commits.
5275
+ const { controller } = setupController(undefined, {
5276
+ frontModelConfig: endpointingConfig({ endpointExtensionMs: 20 }),
5277
+ });
5278
+
5279
+ await controller.handleCallerUtterance("Nice. Actually,");
5280
+ controller.handleCallerPartial("can you check Cleveland");
5281
+
5282
+ // Well past the hold extension, still inside the provider's window.
5283
+ await new Promise((r) => setTimeout(r, 200));
5284
+ expect(legs).toHaveLength(1);
5285
+
5286
+ // The provider's final owns the boundary, and it asks once, on the
5287
+ // committed text rather than the interim guess.
5288
+ await controller.handleCallerUtterance("can you check out Cleveland?");
5289
+
5290
+ expect(legs).toHaveLength(2);
5291
+ expect(legs[1].content).toBe(
5292
+ "Nice. Actually, can you check out Cleveland?",
5293
+ );
5294
+
5295
+ controller.destroy();
5296
+ });
5297
+
5298
+ test("the replay carries the interim words when the provider misses its own window", async () => {
5299
+ const { legs } = mockHoldingBridge();
5300
+ // A provider that has gone quiet past its commit window plus the
5301
+ // margin is gone, not slow, so the interim text is the only record of
5302
+ // what the caller said.
5303
+ loadConfig().calls.voice.utteranceEndMs = 5;
5304
+ const { controller } = setupController(undefined, {
5305
+ frontModelConfig: endpointingConfig({ endpointExtensionMs: 20 }),
5306
+ });
5307
+
5308
+ await controller.handleCallerUtterance("Nice. Actually,");
5309
+ controller.handleCallerPartial("can you check Cleveland");
5310
+
5311
+ await pollUntil(() => legs.length === 2, 5_000);
5312
+
5313
+ expect(legs[1].content).toBe("Nice. Actually, can you check Cleveland");
5314
+
5315
+ controller.destroy();
5316
+ });
5317
+
5318
+ test("interim words re-arm the replay so it fires only once the caller stops", async () => {
5319
+ const { legs } = mockHoldingBridge();
5320
+ loadConfig().calls.voice.utteranceEndMs = 5;
5321
+ const { controller } = setupController(undefined, {
5322
+ frontModelConfig: endpointingConfig({ endpointExtensionMs: 60 }),
5323
+ });
5324
+
5325
+ await controller.handleCallerUtterance("Nice. Actually,");
5326
+
5327
+ // Keep talking across what would otherwise be two replay windows.
5328
+ for (let i = 0; i < 4; i++) {
5329
+ await new Promise((r) => setTimeout(r, 30));
5330
+ controller.handleCallerPartial(`still going ${i}`);
5331
+ }
5332
+ expect(legs).toHaveLength(1);
5333
+
5334
+ await pollUntil(() => legs.length === 2, 5_000);
5335
+ expect(legs[1].content).toBe("Nice. Actually, still going 3");
5336
+
5337
+ controller.destroy();
5338
+ });
5339
+
5340
+ test("the hold cap makes the front door answer the sentence as it stands", async () => {
5341
+ const { legs } = mockHoldingBridge();
5342
+ const { controller } = setupController(undefined, {
5343
+ frontModelConfig: endpointingConfig({
5344
+ endpointExtensionMs: 10_000,
5345
+ endpointMaxExtensions: 2,
5346
+ }),
5347
+ });
5348
+
5349
+ await controller.handleCallerUtterance("So,");
5350
+ await controller.handleCallerUtterance("um,");
5351
+ await controller.handleCallerUtterance("what is the weather?");
5352
+
5353
+ expect(legs.map((leg) => leg.unifiedVerdict)).toEqual([
5354
+ true,
5355
+ true,
5356
+ false,
5357
+ ]);
5358
+ expect(legs[2].content).toBe("So, um, what is the weather?");
5359
+
5360
+ controller.destroy();
5361
+ });
5362
+
5363
+ test("a committed turn resets the hold cap for the caller's next sentence", async () => {
5364
+ const { legs } = mockHoldingBridge();
5365
+ const { controller } = setupController(undefined, {
5366
+ frontModelConfig: endpointingConfig({
5367
+ endpointExtensionMs: 10_000,
5368
+ endpointMaxExtensions: 1,
5369
+ }),
5370
+ });
5371
+
5372
+ await controller.handleCallerUtterance("So,");
5373
+ await controller.handleCallerUtterance("what is the weather?");
5374
+ expect(legs.map((leg) => leg.unifiedVerdict)).toEqual([true, false]);
5375
+
5376
+ await controller.handleCallerUtterance("And,");
5377
+
5378
+ expect(legs[2].unifiedVerdict).toBe(true);
5379
+ expect(legs[2].content).toBe("And,");
5380
+
5381
+ controller.destroy();
5382
+ });
5383
+
5384
+ test("a hold records an endpoint decision and leaves no completed turn", async () => {
5385
+ mockHoldingBridge();
5386
+ const { controller } = setupController(undefined, {
5387
+ frontModelConfig: endpointingConfig({ endpointExtensionMs: 10_000 }),
5388
+ });
5389
+
5390
+ await controller.handleCallerUtterance("Nice. Actually,");
5391
+
5392
+ const turns = controller.getMetricsSnapshot().recentTurns;
5393
+ expect(turns).toHaveLength(1);
5394
+ expect(turns[0].status).toBe("cancelled");
5395
+ expect(turns[0].cancellationReason).toBe("endpoint_hold");
5396
+ expect(turns[0].endpointHoldCount).toBe(1);
5397
+
5398
+ controller.destroy();
5399
+ });
5400
+
5401
+ test("an interim transcript seeds the turn its own utterance dispatches", async () => {
5402
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(["On it."]));
5403
+ const { controller } = setupController();
5404
+
5405
+ controller.handleCallerPartial("what is the");
5406
+ await controller.handleCallerUtterance("what is the weather?");
5407
+
5408
+ const turns = controller.getMetricsSnapshot().recentTurns;
5409
+ expect(turns).toHaveLength(1);
5410
+ expect(turns[0].timestamps.firstPartialAtMs).not.toBeNull();
5411
+
5412
+ controller.destroy();
5413
+ });
5414
+ });
5143
5415
  });
@@ -100,6 +100,7 @@ const mockStartInitialGreeting = jest.fn(async () => {});
100
100
  const mockStartPostVerificationGreeting = jest.fn(async () => {});
101
101
  const mockMarkNextCallerTurnAsOpeningAck = jest.fn();
102
102
  const mockHandleCallerUtterance = jest.fn(async () => {});
103
+ const mockHandleCallerPartial = jest.fn();
103
104
  const mockHandleInterrupt = jest.fn();
104
105
  const mockDestroy = jest.fn();
105
106
 
@@ -116,6 +117,7 @@ mock.module("../call-controller.js", () => ({
116
117
  startPostVerificationGreeting: mockStartPostVerificationGreeting,
117
118
  markNextCallerTurnAsOpeningAck: mockMarkNextCallerTurnAsOpeningAck,
118
119
  handleCallerUtterance: mockHandleCallerUtterance,
120
+ handleCallerPartial: mockHandleCallerPartial,
119
121
  handleInterrupt: mockHandleInterrupt,
120
122
  handleBargeIn: mockHandleBargeIn,
121
123
  destroy: mockDestroy,
@@ -560,6 +562,7 @@ beforeEach(() => {
560
562
  mockStartPostVerificationGreeting.mockClear();
561
563
  mockMarkNextCallerTurnAsOpeningAck.mockClear();
562
564
  mockHandleCallerUtterance.mockClear();
565
+ mockHandleCallerPartial.mockClear();
563
566
  mockHandleInterrupt.mockClear();
564
567
  mockHandleBargeIn.mockClear();
565
568
  mockHandleBargeIn.mockReturnValue(false);
@@ -2182,6 +2185,16 @@ describe("setup flows over the media-stream transport", () => {
2182
2185
  ).handleTranscriptFinal(text, 500);
2183
2186
  }
2184
2187
 
2188
+ /**
2189
+ * Deliver an interim caller transcript, mirroring the STT session's
2190
+ * onTranscriptPartial callback wiring.
2191
+ */
2192
+ function deliverPartial(session: MediaStreamCallSession, text: string): void {
2193
+ (
2194
+ session as unknown as { handleTranscriptPartial(text: string): void }
2195
+ ).handleTranscriptPartial(text);
2196
+ }
2197
+
2185
2198
  function enterDigits(session: MediaStreamCallSession, digits: string): void {
2186
2199
  for (const digit of digits) {
2187
2200
  session.handleMessage(makeDtmfMessage(digit));
@@ -2242,6 +2255,33 @@ describe("setup flows over the media-stream transport", () => {
2242
2255
  await sleep(5);
2243
2256
  expect(mockHandleCallerUtterance).toHaveBeenCalledWith("hello there");
2244
2257
 
2258
+ // Interim transcripts reach the controller too, without recording a
2259
+ // caller_spoke event: they are not the caller's committed words.
2260
+ const spokenBefore = callerSpokeEvents("call-verif-ok").length;
2261
+ deliverPartial(session, "and one more");
2262
+ expect(mockHandleCallerPartial).toHaveBeenCalledWith("and one more");
2263
+ expect(callerSpokeEvents("call-verif-ok")).toHaveLength(spokenBefore);
2264
+
2265
+ session.destroy();
2266
+ });
2267
+
2268
+ test("interim transcripts during a setup flow are dropped", async () => {
2269
+ const { session } = setupCall({
2270
+ callId: "call-verif-partial",
2271
+ outcome: {
2272
+ action: "verification",
2273
+ assistantId: "self",
2274
+ fromNumber: FROM,
2275
+ },
2276
+ });
2277
+ await session.whenSetupSettled();
2278
+
2279
+ // The flow owns caller speech and commits on finals only.
2280
+ expect(session.getSetupFlow()?.getState()).toBe("collecting_code");
2281
+ deliverPartial(session, "one two three");
2282
+
2283
+ expect(mockHandleCallerPartial).not.toHaveBeenCalled();
2284
+
2245
2285
  session.destroy();
2246
2286
  });
2247
2287
 
@@ -878,6 +878,39 @@ describe("MediaStreamSttSession", () => {
878
878
  session.dispose();
879
879
  });
880
880
 
881
+ test("partial events map to onTranscriptPartial; empty partials are suppressed", async () => {
882
+ const onTranscriptPartial = jest.fn();
883
+ const onTranscriptFinal = jest.fn();
884
+ const { session, fake } = await startStreamingSession({
885
+ onTranscriptPartial,
886
+ onTranscriptFinal,
887
+ });
888
+
889
+ fake.emit({ type: "partial", text: " can you check " });
890
+
891
+ expect(onTranscriptPartial).toHaveBeenCalledTimes(1);
892
+ expect(onTranscriptPartial).toHaveBeenCalledWith("can you check");
893
+ // A partial is not a boundary: no reply is triggered.
894
+ expect(onTranscriptFinal).not.toHaveBeenCalled();
895
+
896
+ fake.emit({ type: "partial", text: " " });
897
+ expect(onTranscriptPartial).toHaveBeenCalledTimes(1);
898
+
899
+ session.dispose();
900
+ });
901
+
902
+ test("partials are not forwarded after dispose", async () => {
903
+ const onTranscriptPartial = jest.fn();
904
+ const { session, fake } = await startStreamingSession({
905
+ onTranscriptPartial,
906
+ });
907
+
908
+ session.dispose();
909
+ fake.emit({ type: "partial", text: "too late" });
910
+
911
+ expect(onTranscriptPartial).not.toHaveBeenCalled();
912
+ });
913
+
881
914
  test("final events map to onTranscriptFinal; empty finals are suppressed", async () => {
882
915
  const onTranscriptFinal = jest.fn();
883
916
  const { session, fake } = await startStreamingSession({
@@ -90,6 +90,16 @@ export const POST_GOODBYE_HANGUP_DELAY_MS = 3_000;
90
90
  /** Cap on waiting for a superseded turn's teardown before the next caller turn starts. */
91
91
  export const BARGE_IN_TEARDOWN_WAIT_MS = 2_000;
92
92
 
93
+ /**
94
+ * Slack added to the provider's own `calls.voice.utteranceEndMs` window
95
+ * before a held boundary stops waiting for a final that is not coming. The
96
+ * provider commits an utterance that long after the caller stops speaking,
97
+ * so anything past it plus a frame's network and parse hop means the stream
98
+ * is gone, not slow. Mirrors live voice's provider end-of-turn fallback
99
+ * margin, which bounds the same wait on the other driver.
100
+ */
101
+ export const PROVIDER_FINAL_FALLBACK_MARGIN_MS = 1_000;
102
+
93
103
  export function getSilenceTimeoutMs(): number {
94
104
  return CALLER_SILENCE_NUDGE_MS;
95
105
  }