@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.
- package/.env.example +2 -0
- package/ARCHITECTURE.md +14 -14
- package/docs/architecture/integrations.md +5 -5
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
- package/node_modules/@vellumai/gateway-client/src/__tests__/route-trust-class.test.ts +111 -0
- package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -2
- package/node_modules/@vellumai/gateway-client/src/index.ts +9 -0
- package/node_modules/@vellumai/gateway-client/src/route-trust-class.ts +94 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
- package/openapi.yaml +153 -5
- package/package.json +1 -1
- package/src/__tests__/channel-policy.test.ts +7 -0
- package/src/__tests__/conversation-routes-disk-view.test.ts +141 -2
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
- package/src/__tests__/openai-provider.test.ts +27 -0
- package/src/__tests__/skill-execute-input.test.ts +11 -0
- package/src/__tests__/tool-approval-handler.test.ts +4 -0
- package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
- package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
- package/src/agent/loop.ts +133 -12
- package/src/calls/__tests__/call-controller.test.ts +273 -1
- package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
- package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
- package/src/calls/call-constants.ts +10 -0
- package/src/calls/call-controller.ts +359 -25
- package/src/calls/media-stream-server.ts +18 -0
- package/src/calls/media-stream-stt-session.ts +30 -9
- package/src/channels/config.ts +8 -0
- package/src/cli/lib/bundled-marketplace.json +39 -0
- package/src/config/composer-preferences.test.ts +97 -0
- package/src/config/composer-preferences.ts +95 -0
- package/src/config/feature-flag-registry.json +16 -8
- package/src/config/schemas/llm.ts +1 -0
- package/src/contacts/types.ts +1 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
- package/src/daemon/conversation-agent-loop.ts +3 -0
- package/src/daemon/conversation-messaging.ts +4 -0
- package/src/daemon/conversation-process.ts +15 -0
- package/src/daemon/conversation-queue-manager.ts +2 -0
- package/src/daemon/conversation.ts +2 -0
- package/src/daemon/handlers/shared.ts +13 -2
- package/src/daemon/message-types/sync.ts +1 -0
- package/src/daemon/process-message.ts +5 -0
- package/src/i18n/__tests__/i18n.test.ts +25 -0
- package/src/i18n/assistant-text.ts +18 -0
- package/src/i18n/index.ts +1 -0
- package/src/i18n/messages.ts +11 -0
- package/src/ipc/routes/__tests__/route-adapter.test.ts +40 -0
- package/src/ipc/routes/route-adapter.ts +9 -0
- package/src/notifications/README.md +1 -1
- package/src/notifications/signal.ts +4 -0
- package/src/persistence/conversation-key-store.ts +12 -11
- package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
- package/src/providers/inference/adapter-factory.ts +6 -0
- package/src/providers/ionet/client.test.ts +143 -0
- package/src/providers/ionet/client.ts +37 -0
- package/src/providers/model-catalog.ts +40 -0
- package/src/providers/openai/chat-completions-provider.ts +33 -2
- package/src/providers/retry.ts +10 -4
- package/src/providers/unparseable-tool-args.ts +29 -5
- package/src/runtime/__tests__/http-router-trust.test.ts +360 -0
- package/src/runtime/__tests__/shared-principal-lookup.test.ts +346 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -1
- package/src/runtime/auth/__tests__/route-policy.test.ts +85 -1
- package/src/runtime/auth/__tests__/scopes.test.ts +36 -0
- package/src/runtime/auth/route-policy.ts +44 -0
- package/src/runtime/auth/scopes.ts +15 -0
- package/src/runtime/auth/types.ts +1 -0
- package/src/runtime/channel-reply-delivery.ts +2 -0
- package/src/runtime/http-router.ts +61 -1
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
- package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
- package/src/runtime/migrations/vbundle-builder.ts +19 -3
- package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
- package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
- package/src/runtime/migrations/vbundle-validator.ts +54 -1
- package/src/runtime/routes/composer-settings-routes.test.ts +71 -0
- package/src/runtime/routes/composer-settings-routes.ts +76 -0
- package/src/runtime/routes/conversation-routes.ts +54 -14
- package/src/runtime/routes/identity-routes.ts +2 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/migration-routes.ts +3 -1
- package/src/runtime/routes/types.ts +4 -1
- package/src/runtime/shared-principal-lookup.ts +235 -0
- package/src/tools/host-terminal/host-shell.ts +4 -2
- package/src/tools/shared/shell-output.test.ts +42 -0
- package/src/tools/shared/shell-output.ts +27 -4
- package/src/tools/skills/execute.ts +10 -7
- package/src/tools/terminal/shell.ts +2 -0
- 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 {
|
|
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
|
}
|