@vellumai/assistant 0.11.3 → 0.11.4-staging.2
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 +11 -6
- package/docs/architecture/memory.md +11 -0
- package/docs/architecture/turn-actor.md +70 -0
- package/docs/flux-turn-detection-spike.md +243 -0
- package/docs/stt-provider-onboarding.md +3 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +34 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/openapi.yaml +139 -37
- package/package.json +1 -1
- package/scripts/voice-ttft-spike.ts +3 -3
- package/src/__tests__/app-compiler.test.ts +38 -3
- package/src/__tests__/attachments-store.test.ts +21 -12
- package/src/__tests__/byok-default-profile-ensure.test.ts +17 -0
- package/src/__tests__/call-setup-flow-name-capture.test.ts +0 -1
- package/src/__tests__/call-site-routing-provider.test.ts +1 -1
- package/src/__tests__/channel-availability-routes.test.ts +14 -1
- package/src/__tests__/channel-capabilities-dedupe.test.ts +214 -0
- package/src/__tests__/channel-delivery-store.test.ts +14 -14
- package/src/__tests__/config-loader-backfill.test.ts +3 -3
- package/src/__tests__/config-schema.test.ts +25 -10
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +8 -11
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +8 -11
- package/src/__tests__/conversation-agent-loop.test.ts +28 -20
- package/src/__tests__/conversation-attention-store.test.ts +63 -0
- package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -4
- package/src/__tests__/conversation-fork-crud.test.ts +69 -0
- package/src/__tests__/conversation-fork-referential.test.ts +67 -0
- package/src/__tests__/conversation-fork-retrospective.test.ts +24 -0
- package/src/__tests__/conversation-notifiers-provenance.test.ts +59 -0
- package/src/__tests__/conversation-queue.test.ts +177 -4
- package/src/__tests__/conversation-runtime-assembly.test.ts +134 -102
- package/src/__tests__/conversation-runtime-workspace.test.ts +14 -10
- package/src/__tests__/credential-prompt-route.test.ts +7 -10
- package/src/__tests__/custom-profile-ensure.test.ts +5 -1
- package/src/__tests__/discord-access-request-privacy.test.ts +5 -1
- package/src/__tests__/discord-requester-notice-privacy.test.ts +3 -3
- package/src/__tests__/document-append-idempotency.test.ts +233 -0
- package/src/__tests__/edit-propagation.test.ts +0 -7
- package/src/__tests__/helpers/mock-actor-context.ts +49 -0
- package/src/__tests__/helpers/mock-conversation.ts +13 -1
- package/src/__tests__/injector-chain.test.ts +63 -41
- package/src/__tests__/injector-disk-pressure.test.ts +11 -23
- package/src/__tests__/llm-context-resolution.test.ts +73 -1
- package/src/__tests__/llm-schema.test.ts +5 -2
- package/src/__tests__/mcp-list-plugin-servers.test.ts +250 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +6 -5
- package/src/__tests__/messages-read-boundary-guard.test.ts +134 -0
- package/src/__tests__/mtime-cache.test.ts +1 -1
- package/src/__tests__/non-member-access-request.test.ts +0 -20
- package/src/__tests__/outbound-slack-persistence.test.ts +40 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -0
- package/src/__tests__/plugin-secret-pattern-contribution.test.ts +1 -1
- package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +14 -7
- package/src/__tests__/provider-commit-message-generator.test.ts +20 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +434 -105
- package/src/__tests__/scoped-approval-grants.test.ts +11 -6
- package/src/__tests__/secret-ingress-channel.test.ts +0 -1
- package/src/__tests__/skills.test.ts +32 -0
- package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
- package/src/__tests__/subagent-call-site-routing.test.ts +31 -19
- package/src/__tests__/subagent-spawn-and-await.test.ts +14 -10
- package/src/__tests__/turn-events-store.test.ts +43 -0
- package/src/__tests__/ui-shape-teaching.test.ts +33 -0
- package/src/__tests__/ui-voice-picker-surface.test.ts +128 -0
- package/src/__tests__/user-plugin-loader.test.ts +1 -1
- package/src/__tests__/visible-app-context.test.ts +16 -9
- package/src/__tests__/voice-config-update.test.ts +40 -0
- package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
- package/src/__tests__/worker-plugin-surface.test.ts +77 -0
- package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
- package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
- package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
- package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
- package/src/__tests__/workspace-migration-146-repair-retired-fireworks-deepseek-flash-model-id.test.ts +235 -0
- package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
- package/src/acp/__tests__/auth-required.test.ts +161 -0
- package/src/acp/acp-claude-oauth.ts +19 -2
- package/src/acp/agent-process.test.ts +100 -0
- package/src/acp/agent-process.ts +29 -26
- package/src/acp/auth-required.ts +102 -0
- package/src/acp/session-manager.test.ts +119 -0
- package/src/acp/session-manager.ts +68 -2
- package/src/api/events/acp-auth-required.ts +55 -0
- package/src/api/index.ts +7 -0
- package/src/api/surfaces.ts +7 -3
- package/src/apps/app-store.ts +3 -0
- package/src/bundler/package-resolver.ts +2 -30
- package/src/calls/__tests__/voice-session-bridge.test.ts +173 -1
- package/src/calls/__tests__/voice-triage-escalate.test.ts +94 -0
- package/src/calls/call-controller.ts +19 -3
- package/src/calls/call-setup-flow.ts +0 -1
- package/src/calls/media-stream-stt-session.ts +15 -0
- package/src/calls/voice-session-bridge.ts +71 -16
- package/src/calls/voice-triage-escalate.ts +104 -2
- package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
- package/src/channels/config.ts +13 -0
- package/src/channels/plugin-channel-declarations.ts +108 -0
- package/src/channels/types.ts +30 -0
- package/src/cli/AGENTS.md +5 -2
- package/src/cli/commands/credentials.help.ts +2 -2
- package/src/cli/commands/inference-providers.ts +1 -1
- package/src/cli/commands/mcp.help.ts +13 -4
- package/src/cli/commands/mcp.ts +9 -0
- package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
- package/src/cli/commands/memory/index.help.ts +43 -1
- package/src/cli/commands/memory/memory-v3.ts +64 -0
- package/src/cli/commands/stt.help.ts +27 -2
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
- package/src/cli/lib/bundled-marketplace.json +13 -0
- package/src/cli/lib/upgrade-plugin.ts +42 -0
- package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
- package/src/config/__tests__/default-provider.test.ts +6 -1
- package/src/config/__tests__/profile-materialization.test.ts +75 -19
- package/src/config/bundled-skills/acp/SKILL.md +6 -7
- package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
- package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
- package/src/config/bundled-skills/settings/TOOLS.json +3 -3
- package/src/config/bundled-skills/settings/tools/navigate-settings-tab.test.ts +65 -0
- package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +7 -1
- package/src/config/bundled-skills/settings/tools/shared.ts +16 -0
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -1
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
- package/src/config/call-site-defaults.ts +4 -5
- package/src/config/default-profile-catalog.ts +83 -12
- package/src/config/default-profile-names.ts +4 -1
- package/src/config/default-provider-resolution.ts +4 -0
- package/src/config/llm-context-resolution.ts +11 -3
- package/src/config/llm-resolver.ts +28 -1
- package/src/config/profile-materialization.ts +70 -22
- package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
- package/src/config/schemas/call-site-catalog.ts +4 -4
- package/src/config/schemas/live-voice.ts +57 -23
- package/src/config/schemas/llm.ts +59 -32
- package/src/config/schemas/mcp.ts +23 -0
- package/src/config/schemas/plugin-updates.ts +6 -2
- package/src/config/schemas/stt.ts +1 -0
- package/src/context/outbound-sanitize.ts +96 -1
- package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
- package/src/daemon/conversation-agent-loop.ts +17 -6
- package/src/daemon/conversation-messaging.ts +5 -1
- package/src/daemon/conversation-notifiers.ts +9 -1
- package/src/daemon/conversation-process.ts +36 -6
- package/src/daemon/conversation-runtime-assembly.ts +3 -4
- package/src/daemon/conversation-surfaces.ts +27 -5
- package/src/daemon/conversation-tool-setup.ts +1 -2
- package/src/daemon/conversation.ts +48 -0
- package/src/daemon/interactive-turn-sender.ts +59 -0
- package/src/daemon/mcp-reload-service.ts +36 -6
- package/src/daemon/process-message.ts +24 -24
- package/src/daemon/providers-setup.ts +6 -3
- package/src/daemon/trust-context-types.ts +29 -0
- package/src/daemon/wake-conversation-ops.ts +3 -2
- package/src/documents/document-store.ts +138 -5
- package/src/hooks/hook-loader.ts +3 -3
- package/src/hooks/registry.ts +50 -6
- package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
- package/src/inbound/oauth-callback-url.ts +61 -0
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
- package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +932 -0
- package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
- package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
- package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
- package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
- package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
- package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
- package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
- package/src/live-voice/live-voice-archive.ts +2 -0
- package/src/live-voice/live-voice-metrics.ts +57 -32
- package/src/live-voice/live-voice-photo.ts +1 -2
- package/src/live-voice/live-voice-session.ts +535 -314
- package/src/live-voice/progress-narration.ts +277 -0
- package/src/live-voice/protocol.ts +21 -1
- package/src/mcp/__tests__/effective-config.test.ts +238 -0
- package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
- package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
- package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
- package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
- package/src/mcp/client.ts +16 -11
- package/src/mcp/effective-config.ts +113 -0
- package/src/mcp/manager.ts +11 -6
- package/src/mcp/mcp-auth-orchestrator.ts +13 -22
- package/src/mcp/mcp-oauth-provider.ts +205 -240
- package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
- package/src/monitoring/plugin-auto-update.ts +128 -24
- package/src/notifications/signal.ts +1 -0
- package/src/permissions/confirmation-guardian-request.test.ts +15 -11
- package/src/permissions/confirmation-guardian-request.ts +2 -2
- package/src/permissions/question-guardian-request.test.ts +14 -6
- package/src/permissions/question-guardian-request.ts +1 -2
- package/src/persistence/attachments-store.ts +8 -1
- package/src/persistence/bookmark-crud.ts +3 -7
- package/src/persistence/conversation-attention-store.ts +16 -45
- package/src/persistence/conversation-crud.ts +33 -4
- package/src/persistence/conversation-lineage.ts +9 -0
- package/src/persistence/conversation-queries.ts +108 -41
- package/src/persistence/delivery-crud.ts +38 -29
- package/src/persistence/external-conversation-store.ts +32 -4
- package/src/persistence/llm-request-log-store.ts +4 -10
- package/src/persistence/llm-usage-store.ts +8 -3
- package/src/persistence/message-reads.test.ts +197 -0
- package/src/persistence/message-reads.ts +211 -0
- package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
- package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
- package/src/persistence/real-user-turn-filter.ts +27 -3
- package/src/persistence/steps.ts +9 -0
- package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
- package/src/plugin-api/conversation-turn.ts +168 -5
- package/src/plugin-api/index.ts +21 -5
- package/src/plugin-api/vision-support.test.ts +1 -1
- package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
- package/src/plugins/defaults/memory/AGENTS.md +4 -0
- package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
- package/src/plugins/defaults/memory/buffer-format.ts +165 -0
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
- package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
- package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
- package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +8 -12
- package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
- package/src/plugins/defaults/memory/indexer.ts +3 -1
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
- package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
- package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +33 -0
- package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +199 -2
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +25 -18
- package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
- package/src/plugins/defaults/memory/substrate/static-context.ts +160 -4
- package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
- package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
- package/src/plugins/defaults/memory/v3/prune.ts +2 -0
- package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
- package/src/plugins/defaults/memory/worker.ts +6 -3
- package/src/plugins/external-plugin-loader.ts +47 -0
- package/src/plugins/mcp-servers.ts +361 -0
- package/src/plugins/mtime-cache.ts +23 -49
- package/src/plugins/worker-plugin-surface.ts +33 -0
- package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
- package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
- package/src/providers/__tests__/retry-callsite.test.ts +5 -2
- package/src/providers/call-site-routing.ts +30 -3
- package/src/providers/connection-resolution.ts +194 -11
- package/src/providers/inference/auth.ts +6 -0
- package/src/providers/inference/connection-availability.ts +24 -2
- package/src/providers/inference/connections.ts +2 -0
- package/src/providers/model-catalog.ts +3 -3
- package/src/providers/model-intents.ts +28 -8
- package/src/providers/openai/chat-completions-provider.ts +5 -6
- package/src/providers/openai/codex-models.ts +2 -1
- package/src/providers/provider-send-message.ts +32 -3
- package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
- package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +620 -0
- package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
- package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
- package/src/providers/speech-to-text/deepgram-flux-realtime.ts +719 -0
- package/src/providers/speech-to-text/provider-catalog.ts +99 -8
- package/src/providers/speech-to-text/resolve.ts +25 -2
- package/src/routes/worker.ts +17 -5
- package/src/runtime/access-request-helper.ts +9 -12
- package/src/runtime/agent-wake.ts +3 -3
- package/src/runtime/pre-first-message-gate.ts +4 -0
- package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +219 -1
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
- package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +44 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +62 -1
- package/src/runtime/routes/channel-availability-routes.ts +32 -14
- package/src/runtime/routes/channel-route-shared.ts +0 -6
- package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
- package/src/runtime/routes/conversation-list-routes.ts +112 -1
- package/src/runtime/routes/conversation-query-routes.ts +40 -27
- package/src/runtime/routes/credential-prompt-routes.ts +4 -7
- package/src/runtime/routes/default-provider-routes.ts +15 -0
- package/src/runtime/routes/inbound-message-handler.ts +17 -41
- package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
- package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -20
- package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
- package/src/runtime/routes/inference-profiles-routes.ts +20 -11
- package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
- package/src/runtime/routes/log-export-routes.ts +3 -0
- package/src/runtime/routes/mcp-auth-routes.ts +148 -57
- package/src/runtime/routes/plugins-routes.ts +21 -3
- package/src/runtime/routes/stt-routes.ts +31 -25
- package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
- package/src/runtime/routes/user-route-dispatcher.ts +39 -14
- package/src/runtime/routes/user-route-import.ts +108 -0
- package/src/schedule/worker.ts +6 -0
- package/src/security/oauth2.ts +6 -22
- package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
- package/src/stt/__tests__/types.test.ts +94 -0
- package/src/stt/daemon-batch-transcriber.ts +10 -0
- package/src/stt/stt-stream-session.ts +8 -4
- package/src/stt/types.ts +103 -0
- package/src/subagent/manager.ts +1 -3
- package/src/subagent/types.ts +7 -6
- package/src/tools/acp/spawn.test.ts +97 -0
- package/src/tools/acp/spawn.ts +32 -0
- package/src/tools/document/document-tool.ts +12 -3
- package/src/tools/registry.ts +2 -1
- package/src/tools/ui-surface/surface-shape-docs.ts +11 -0
- package/src/tools/workflows/run-workflow.ts +1 -2
- package/src/tts/__tests__/reasoning-tag-filter.test.ts +78 -0
- package/src/tts/reasoning-tag-filter.ts +89 -0
- package/src/util/think-tag-stream.ts +95 -0
- package/src/workspace/byok-default-profile-ensure.ts +76 -24
- package/src/workspace/custom-profile-ensure.ts +4 -24
- package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
- package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
- package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
- package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
- package/src/workspace/migrations/146-repair-retired-fireworks-deepseek-flash-model-id.ts +195 -0
- package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
- package/src/workspace/migrations/registry.ts +10 -0
- package/src/workspace/provider-commit-message-generator.ts +7 -5
- package/src/live-voice/__tests__/front-decision.test.ts +0 -645
- package/src/live-voice/front-decision.ts +0 -476
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { setConfig } from "../../__tests__/helpers/set-config.js";
|
|
4
|
+
import type {
|
|
5
|
+
VoiceTurnCallbacks,
|
|
6
|
+
VoiceTurnOptions,
|
|
7
|
+
} from "../../calls/voice-session-bridge.js";
|
|
8
|
+
import type { LiveVoiceFluxConfig } from "../../config/schemas/live-voice.js";
|
|
9
|
+
import type {
|
|
10
|
+
StreamingTranscriber,
|
|
11
|
+
SttProviderId,
|
|
12
|
+
SttStreamServerEvent,
|
|
13
|
+
} from "../../stt/types.js";
|
|
14
|
+
import {
|
|
15
|
+
LiveVoiceSession,
|
|
16
|
+
type LiveVoiceTtsStreamer,
|
|
17
|
+
} from "../live-voice-session.js";
|
|
18
|
+
import type { LiveVoiceSessionFactoryContext } from "../live-voice-session-manager.js";
|
|
19
|
+
import type {
|
|
20
|
+
LiveVoiceTtsOptions,
|
|
21
|
+
LiveVoiceTtsResult,
|
|
22
|
+
} from "../live-voice-tts.js";
|
|
23
|
+
import {
|
|
24
|
+
createLiveVoiceServerFrameSequencer,
|
|
25
|
+
type LiveVoiceClientStartFrame,
|
|
26
|
+
type LiveVoiceServerFrame,
|
|
27
|
+
} from "../protocol.js";
|
|
28
|
+
|
|
29
|
+
const SAMPLE_RATE = 24_000;
|
|
30
|
+
|
|
31
|
+
const VAD_START_FRAME = {
|
|
32
|
+
type: "start",
|
|
33
|
+
conversationId: "conversation-123",
|
|
34
|
+
turnDetection: "server_vad",
|
|
35
|
+
audio: {
|
|
36
|
+
mimeType: "audio/pcm",
|
|
37
|
+
sampleRate: SAMPLE_RATE,
|
|
38
|
+
channels: 1,
|
|
39
|
+
},
|
|
40
|
+
} as const satisfies LiveVoiceClientStartFrame;
|
|
41
|
+
|
|
42
|
+
function pcm(amplitude: number, sampleCount = 240): Uint8Array {
|
|
43
|
+
const buffer = Buffer.alloc(sampleCount * 2);
|
|
44
|
+
for (let index = 0; index < sampleCount; index += 1) {
|
|
45
|
+
buffer.writeInt16LE(amplitude, index * 2);
|
|
46
|
+
}
|
|
47
|
+
return new Uint8Array(buffer);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 10 ms of speech at 24 kHz.
|
|
51
|
+
const LOUD_CHUNK = pcm(8_000);
|
|
52
|
+
// 300 ms of speech at 24 kHz, comfortably past the default sustained-speech
|
|
53
|
+
// barge-in guard (250 ms) in a single chunk.
|
|
54
|
+
const SUSTAINED_LOUD_CHUNK = pcm(8_000, 7_200);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Flux-shaped streaming transcriber: no `finalizeUtterance` (Flux owns turn
|
|
58
|
+
* boundaries, so the session runs it per cycle), and every transcript event is
|
|
59
|
+
* scripted by the test. `stop()` closes without inventing a trailing final,
|
|
60
|
+
* exactly as Flux does: its transcript is committed by EndOfTurn.
|
|
61
|
+
*/
|
|
62
|
+
class MockFluxTranscriber implements StreamingTranscriber {
|
|
63
|
+
readonly boundaryId = "daemon-streaming" as const;
|
|
64
|
+
readonly received: Buffer[] = [];
|
|
65
|
+
stopped = false;
|
|
66
|
+
private onEvent: ((event: SttStreamServerEvent) => void) | null = null;
|
|
67
|
+
|
|
68
|
+
constructor(readonly providerId: SttProviderId) {}
|
|
69
|
+
|
|
70
|
+
async start(onEvent: (event: SttStreamServerEvent) => void): Promise<void> {
|
|
71
|
+
this.onEvent = onEvent;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
sendAudio(chunk: Buffer): void {
|
|
75
|
+
this.received.push(Buffer.from(chunk));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
stop(): void {
|
|
79
|
+
if (this.stopped) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
this.stopped = true;
|
|
83
|
+
this.onEvent?.({ type: "closed" });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
emit(event: SttStreamServerEvent): void {
|
|
87
|
+
this.onEvent?.(event);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Flux's StartOfTurn frame, numbering the turn it opens.
|
|
91
|
+
startOfTurn(turnIndex: number): void {
|
|
92
|
+
this.emit({ type: "turn-start", turnIndex });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Flux's EndOfTurn frame maps onto a `final` followed by a `turn-end`.
|
|
96
|
+
endOfTurn(text: string, turnIndex?: number): void {
|
|
97
|
+
this.emit({ type: "final", text });
|
|
98
|
+
this.emit({
|
|
99
|
+
type: "turn-end",
|
|
100
|
+
text,
|
|
101
|
+
confidence: 0.9,
|
|
102
|
+
...(turnIndex !== undefined ? { turnIndex } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function createHarness(options: {
|
|
108
|
+
providerId?: SttProviderId;
|
|
109
|
+
fluxConfig?: Partial<LiveVoiceFluxConfig>;
|
|
110
|
+
silenceThresholdMs?: number;
|
|
111
|
+
startVoiceTurn?: (options: VoiceTurnOptions) => Promise<{
|
|
112
|
+
turnId: string;
|
|
113
|
+
abort: () => void;
|
|
114
|
+
}>;
|
|
115
|
+
streamTtsAudio?: LiveVoiceTtsStreamer | null;
|
|
116
|
+
emitMetrics?: boolean;
|
|
117
|
+
// Holds the STT dial open so a test can drive a whole utterance through the
|
|
118
|
+
// window between `ready` and the resolved provider.
|
|
119
|
+
resolveGate?: Promise<unknown>;
|
|
120
|
+
}) {
|
|
121
|
+
const sequencer = createLiveVoiceServerFrameSequencer();
|
|
122
|
+
const frames: LiveVoiceServerFrame[] = [];
|
|
123
|
+
const context: LiveVoiceSessionFactoryContext = {
|
|
124
|
+
sessionId: "session-123",
|
|
125
|
+
startFrame: VAD_START_FRAME,
|
|
126
|
+
sendFrame: mock(async (payload) => {
|
|
127
|
+
const frame = sequencer.next(payload);
|
|
128
|
+
frames.push(frame);
|
|
129
|
+
return frame;
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const transcribers: MockFluxTranscriber[] = [];
|
|
134
|
+
const resolveTranscriber = mock(async () => {
|
|
135
|
+
if (options.resolveGate) {
|
|
136
|
+
await options.resolveGate;
|
|
137
|
+
}
|
|
138
|
+
const transcriber = new MockFluxTranscriber(
|
|
139
|
+
options.providerId ?? "deepgram-flux",
|
|
140
|
+
);
|
|
141
|
+
transcribers.push(transcriber);
|
|
142
|
+
return transcriber;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const turnCalls: VoiceTurnOptions[] = [];
|
|
146
|
+
const startVoiceTurn = mock(async (turnOptions: VoiceTurnOptions) => {
|
|
147
|
+
turnCalls.push(turnOptions);
|
|
148
|
+
if (options.startVoiceTurn) {
|
|
149
|
+
return await options.startVoiceTurn(turnOptions);
|
|
150
|
+
}
|
|
151
|
+
return { turnId: `bridge-turn-${turnCalls.length}`, abort: mock() };
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const session = new LiveVoiceSession(context, {
|
|
155
|
+
resolveTranscriber,
|
|
156
|
+
startVoiceTurn,
|
|
157
|
+
streamTtsAudio: options.streamTtsAudio ?? null,
|
|
158
|
+
emitMetrics: options.emitMetrics ?? false,
|
|
159
|
+
spawnBackgroundContinuation: mock(async () => ""),
|
|
160
|
+
turnDetectorConfig: {
|
|
161
|
+
silenceThresholdMs: options.silenceThresholdMs ?? 40,
|
|
162
|
+
},
|
|
163
|
+
...(options.fluxConfig ? { fluxConfig: options.fluxConfig } : {}),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return { frames, session, transcribers, turnCalls };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function makeTextDelta(
|
|
170
|
+
text: string,
|
|
171
|
+
): Parameters<NonNullable<VoiceTurnCallbacks["assistant_text_delta"]>>[0] {
|
|
172
|
+
return {
|
|
173
|
+
type: "assistant_text_delta",
|
|
174
|
+
text,
|
|
175
|
+
conversationId: "conversation-123",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function makeMessageComplete(): Parameters<
|
|
180
|
+
NonNullable<VoiceTurnCallbacks["message_complete"]>
|
|
181
|
+
>[0] {
|
|
182
|
+
return {
|
|
183
|
+
type: "message_complete",
|
|
184
|
+
conversationId: "conversation-123",
|
|
185
|
+
messageId: "assistant-message-123",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Answers immediately: one text delta (which commits a speculative leg), then
|
|
190
|
+
// message_complete.
|
|
191
|
+
function autoCompletingTurn(
|
|
192
|
+
reply = "Okay.",
|
|
193
|
+
): (
|
|
194
|
+
options: VoiceTurnOptions,
|
|
195
|
+
) => Promise<{ turnId: string; abort: () => void }> {
|
|
196
|
+
let count = 0;
|
|
197
|
+
return async (options) => {
|
|
198
|
+
count += 1;
|
|
199
|
+
options.callbacks?.assistant_text_delta?.(makeTextDelta(reply));
|
|
200
|
+
options.callbacks?.message_complete?.(makeMessageComplete());
|
|
201
|
+
return { turnId: `bridge-turn-${count}`, abort: mock() };
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function waitFor(
|
|
206
|
+
predicate: () => boolean,
|
|
207
|
+
message = "Timed out waiting for a live voice Flux test condition",
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
const deadline = Date.now() + 4_000;
|
|
210
|
+
while (Date.now() < deadline) {
|
|
211
|
+
if (predicate()) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
215
|
+
}
|
|
216
|
+
throw new Error(message);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function flushAsyncCallbacks(): Promise<void> {
|
|
220
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function sleep(ms: number): Promise<void> {
|
|
224
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Holds the STT dial open until the test opens it, so the whole
|
|
228
|
+
// ready-before-resolve window is under the test's control.
|
|
229
|
+
function createDialGate(): { promise: Promise<void>; open: () => void } {
|
|
230
|
+
let open = (): void => {};
|
|
231
|
+
const promise = new Promise<void>((resolve) => {
|
|
232
|
+
open = () => resolve();
|
|
233
|
+
});
|
|
234
|
+
return { promise, open };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function countFrames(
|
|
238
|
+
frames: LiveVoiceServerFrame[],
|
|
239
|
+
type: LiveVoiceServerFrame["type"],
|
|
240
|
+
): number {
|
|
241
|
+
return frames.filter((frame) => frame.type === type).length;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Waits for the completed turn's metrics frame and hands it back narrowed, so
|
|
245
|
+
// the assertions below read the endpoint fields without a type guard.
|
|
246
|
+
async function waitForTurnMetrics(
|
|
247
|
+
frames: LiveVoiceServerFrame[],
|
|
248
|
+
): Promise<Extract<LiveVoiceServerFrame, { type: "metrics" }>> {
|
|
249
|
+
const isTurnMetrics = (
|
|
250
|
+
frame: LiveVoiceServerFrame,
|
|
251
|
+
): frame is Extract<LiveVoiceServerFrame, { type: "metrics" }> =>
|
|
252
|
+
frame.type === "metrics" && frame.event === "turn_completed";
|
|
253
|
+
await waitFor(
|
|
254
|
+
() => frames.some(isTurnMetrics),
|
|
255
|
+
"The completed turn never reported its metrics",
|
|
256
|
+
);
|
|
257
|
+
const metricsFrame = frames.find(isTurnMetrics);
|
|
258
|
+
if (!metricsFrame) {
|
|
259
|
+
throw new Error("The completed turn never reported its metrics");
|
|
260
|
+
}
|
|
261
|
+
return metricsFrame;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Long enough for the 40 ms local silence timer to fire and hand the boundary
|
|
265
|
+
// to Flux (which arms the fail-open deadline and commits nothing).
|
|
266
|
+
const PAST_SILENCE_BOUNDARY_MS = 150;
|
|
267
|
+
|
|
268
|
+
const FLUX_ON = {
|
|
269
|
+
turnEnd: { enabled: true },
|
|
270
|
+
eotTimeoutMs: 500,
|
|
271
|
+
} as const satisfies Partial<LiveVoiceFluxConfig>;
|
|
272
|
+
|
|
273
|
+
describe("LiveVoiceSession Flux end-of-turn", () => {
|
|
274
|
+
test("commits the turn on turn-end without an endpoint-decision leg", async () => {
|
|
275
|
+
const { frames, session, transcribers, turnCalls } = createHarness({
|
|
276
|
+
fluxConfig: FLUX_ON,
|
|
277
|
+
// Long enough that the local silence boundary cannot be what commits.
|
|
278
|
+
silenceThresholdMs: 10_000,
|
|
279
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await session.start();
|
|
283
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
284
|
+
await waitFor(() => transcribers.length > 0);
|
|
285
|
+
transcribers[0]?.endOfTurn("what is the weather");
|
|
286
|
+
|
|
287
|
+
await waitFor(() => turnCalls.length === 1);
|
|
288
|
+
expect(turnCalls[0]?.content).toBe("what is the weather");
|
|
289
|
+
// Non-speculative dispatch: the front-door leg's decision rule is built
|
|
290
|
+
// with includeHold false, so the hold token is never taught.
|
|
291
|
+
expect(turnCalls[0]?.unifiedVerdict).toBeUndefined();
|
|
292
|
+
expect(turnCalls[0]?.routingLeg).toBe("front-door");
|
|
293
|
+
expect(frames.some((frame) => frame.type === "utterance_end")).toBe(true);
|
|
294
|
+
|
|
295
|
+
await session.close("client_end");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("still escalates on the [1] verdict after a flux commit", async () => {
|
|
299
|
+
const { session, transcribers, turnCalls } = createHarness({
|
|
300
|
+
fluxConfig: FLUX_ON,
|
|
301
|
+
silenceThresholdMs: 10_000,
|
|
302
|
+
startVoiceTurn: async (turnOptions) => {
|
|
303
|
+
if (turnOptions.routingLeg === "front-door") {
|
|
304
|
+
turnOptions.callbacks?.assistant_text_delta?.(
|
|
305
|
+
makeTextDelta("[1] Let me check that for you."),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
return { turnId: "bridge-turn", abort: mock() };
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await session.start();
|
|
313
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
314
|
+
await waitFor(() => transcribers.length > 0);
|
|
315
|
+
transcribers[0]?.endOfTurn("what is on my calendar");
|
|
316
|
+
|
|
317
|
+
await waitFor(
|
|
318
|
+
() => turnCalls.some((call) => call.routingLeg === "escalated"),
|
|
319
|
+
"The escalate verdict never handed off to the escalated leg",
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
await session.close("client_end");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("ignores turn-end when the flag is off and keeps the hold path", async () => {
|
|
326
|
+
const { frames, session, transcribers, turnCalls } = createHarness({
|
|
327
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await session.start();
|
|
331
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
332
|
+
await waitFor(() => transcribers.length > 0);
|
|
333
|
+
transcribers[0]?.emit({ type: "partial", text: "hello there" });
|
|
334
|
+
transcribers[0]?.endOfTurn("hello there");
|
|
335
|
+
await flushAsyncCallbacks();
|
|
336
|
+
|
|
337
|
+
// The turn-end event committed nothing: the silence boundary still owns
|
|
338
|
+
// the commit, and it dispatches speculatively (the hold path).
|
|
339
|
+
await waitFor(() => turnCalls.length === 1);
|
|
340
|
+
expect(turnCalls[0]?.unifiedVerdict).toBe(true);
|
|
341
|
+
expect(frames.some((frame) => frame.type === "utterance_end")).toBe(true);
|
|
342
|
+
|
|
343
|
+
await session.close("client_end");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("ignores turn-end from a provider that is not deepgram-flux", async () => {
|
|
347
|
+
const { session, transcribers, turnCalls } = createHarness({
|
|
348
|
+
providerId: "deepgram",
|
|
349
|
+
fluxConfig: FLUX_ON,
|
|
350
|
+
silenceThresholdMs: 10_000,
|
|
351
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
await session.start();
|
|
355
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
356
|
+
await waitFor(() => transcribers.length > 0);
|
|
357
|
+
transcribers[0]?.endOfTurn("hello there");
|
|
358
|
+
await flushAsyncCallbacks();
|
|
359
|
+
|
|
360
|
+
expect(turnCalls).toHaveLength(0);
|
|
361
|
+
|
|
362
|
+
await session.close("client_end");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("reports the endpoint decision as flux", async () => {
|
|
366
|
+
const { frames, session, transcribers, turnCalls } = createHarness({
|
|
367
|
+
fluxConfig: FLUX_ON,
|
|
368
|
+
silenceThresholdMs: 10_000,
|
|
369
|
+
emitMetrics: true,
|
|
370
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
await session.start();
|
|
374
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
375
|
+
await waitFor(() => transcribers.length > 0);
|
|
376
|
+
transcribers[0]?.endOfTurn("hello there");
|
|
377
|
+
await waitFor(() => turnCalls.length === 1);
|
|
378
|
+
|
|
379
|
+
const metricsFrame = await waitForTurnMetrics(frames);
|
|
380
|
+
expect(metricsFrame.endpointDecisionSource).toBe("provider");
|
|
381
|
+
expect(metricsFrame.endpointHoldCount).toBe(0);
|
|
382
|
+
|
|
383
|
+
await session.close("client_end");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("anchors the flux commit latency at the local speech-stop mark", async () => {
|
|
387
|
+
const { frames, session, transcribers, turnCalls } = createHarness({
|
|
388
|
+
fluxConfig: { turnEnd: { enabled: true }, eotTimeoutMs: 30_000 },
|
|
389
|
+
// Long enough that the local silence boundary cannot be what commits,
|
|
390
|
+
// and that the fail-open deadline stays far away.
|
|
391
|
+
silenceThresholdMs: 10_000,
|
|
392
|
+
emitMetrics: true,
|
|
393
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
await session.start();
|
|
397
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
398
|
+
await waitFor(() => transcribers.length > 0);
|
|
399
|
+
// Silence between the caller's last above-gate chunk and the commit. A
|
|
400
|
+
// dispatch-anchored number would not see it; the speech-stop anchor must.
|
|
401
|
+
await sleep(250);
|
|
402
|
+
transcribers[0]?.endOfTurn("hello there");
|
|
403
|
+
await waitFor(() => turnCalls.length === 1);
|
|
404
|
+
|
|
405
|
+
const metricsFrame = await waitForTurnMetrics(frames);
|
|
406
|
+
expect(metricsFrame.endpointCommitLatencyMs).toBeGreaterThanOrEqual(250);
|
|
407
|
+
|
|
408
|
+
await session.close("client_end");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("records the commit latency on the front-door path too, with no hold", async () => {
|
|
412
|
+
const { frames, session, transcribers, turnCalls } = createHarness({
|
|
413
|
+
// No flux config: the latch is down and the local silence boundary owns
|
|
414
|
+
// the commit, which is arm A of the measurement run.
|
|
415
|
+
silenceThresholdMs: 40,
|
|
416
|
+
emitMetrics: true,
|
|
417
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
await session.start();
|
|
421
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
422
|
+
await waitFor(() => transcribers.length > 0);
|
|
423
|
+
transcribers[0]?.emit({ type: "partial", text: "hello there" });
|
|
424
|
+
await waitFor(() => turnCalls.length === 1);
|
|
425
|
+
|
|
426
|
+
// The turn committed straight through, so it records no endpoint decision
|
|
427
|
+
// at all. The commit latency is recorded regardless, which is what makes
|
|
428
|
+
// the two arms one population rather than two.
|
|
429
|
+
const metricsFrame = await waitForTurnMetrics(frames);
|
|
430
|
+
expect(metricsFrame.endpointDecisionSource).toBeUndefined();
|
|
431
|
+
expect(metricsFrame.endpointCommitLatencyMs).toBeGreaterThanOrEqual(40);
|
|
432
|
+
|
|
433
|
+
await session.close("client_end");
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("records no commit latency for a turn that never committed", async () => {
|
|
437
|
+
const { frames, session, transcribers } = createHarness({
|
|
438
|
+
fluxConfig: { turnEnd: { enabled: true }, eotTimeoutMs: 30_000 },
|
|
439
|
+
silenceThresholdMs: 10_000,
|
|
440
|
+
emitMetrics: true,
|
|
441
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
await session.start();
|
|
445
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
446
|
+
await waitFor(() => transcribers.length > 0);
|
|
447
|
+
transcribers[0]?.emit({ type: "partial", text: "hello the" });
|
|
448
|
+
// Nothing ends this turn: no flux turn-end, no silence boundary, and the
|
|
449
|
+
// fail-open deadline is 30 s away.
|
|
450
|
+
await flushAsyncCallbacks();
|
|
451
|
+
await session.close("client_end");
|
|
452
|
+
|
|
453
|
+
const metricsFrames = frames.filter((frame) => frame.type === "metrics");
|
|
454
|
+
expect(metricsFrames.length).toBeGreaterThan(0);
|
|
455
|
+
for (const frame of metricsFrames) {
|
|
456
|
+
expect(frame).not.toHaveProperty("endpointCommitLatencyMs");
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("falls back to the silence-boundary path when no turn-end arrives", async () => {
|
|
461
|
+
const { session, transcribers, turnCalls } = createHarness({
|
|
462
|
+
fluxConfig: FLUX_ON,
|
|
463
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
await session.start();
|
|
467
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
468
|
+
await waitFor(() => transcribers.length > 0);
|
|
469
|
+
// Flux is transcribing but its EndOfTurn never arrives.
|
|
470
|
+
transcribers[0]?.emit({ type: "partial", text: "are you still there" });
|
|
471
|
+
|
|
472
|
+
// The local silence boundary (40 ms) passes without committing anything:
|
|
473
|
+
// the fallback deadline (eotTimeoutMs plus the margin) owns this
|
|
474
|
+
// utterance.
|
|
475
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
476
|
+
expect(turnCalls).toHaveLength(0);
|
|
477
|
+
|
|
478
|
+
await waitFor(
|
|
479
|
+
() => turnCalls.length === 1,
|
|
480
|
+
"Flux fallback never replayed the silence boundary",
|
|
481
|
+
);
|
|
482
|
+
// Back on the existing path: a speculative dispatch that knows the hold
|
|
483
|
+
// token, judging the partial the silence boundary saw.
|
|
484
|
+
expect(turnCalls[0]?.content).toBe("are you still there");
|
|
485
|
+
expect(turnCalls[0]?.unifiedVerdict).toBe(true);
|
|
486
|
+
|
|
487
|
+
await session.close("client_end");
|
|
488
|
+
}, 10_000);
|
|
489
|
+
|
|
490
|
+
test("commits a turn-end that arrives after the local silence boundary", async () => {
|
|
491
|
+
const { frames, session, transcribers, turnCalls } = createHarness({
|
|
492
|
+
fluxConfig: FLUX_ON,
|
|
493
|
+
silenceThresholdMs: 40,
|
|
494
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await session.start();
|
|
498
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
499
|
+
await waitFor(() => transcribers.length > 0);
|
|
500
|
+
|
|
501
|
+
// The boundary passes and commits nothing: Flux owns it.
|
|
502
|
+
await sleep(PAST_SILENCE_BOUNDARY_MS);
|
|
503
|
+
expect(turnCalls).toHaveLength(0);
|
|
504
|
+
|
|
505
|
+
// No resumed speech, so this turn-end is the one the boundary was waiting
|
|
506
|
+
// for and it commits exactly as before.
|
|
507
|
+
transcribers[0]?.endOfTurn("what is the weather");
|
|
508
|
+
await waitFor(
|
|
509
|
+
() => turnCalls.length === 1,
|
|
510
|
+
"A non-stale turn-end after the silence boundary never committed",
|
|
511
|
+
);
|
|
512
|
+
expect(turnCalls[0]?.content).toBe("what is the weather");
|
|
513
|
+
expect(turnCalls[0]?.unifiedVerdict).toBeUndefined();
|
|
514
|
+
expect(frames.some((frame) => frame.type === "utterance_end")).toBe(true);
|
|
515
|
+
|
|
516
|
+
await session.close("client_end");
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("drops a turn-end that arrives after the caller resumed speaking", async () => {
|
|
520
|
+
const { frames, session, transcribers, turnCalls } = createHarness({
|
|
521
|
+
fluxConfig: FLUX_ON,
|
|
522
|
+
silenceThresholdMs: 40,
|
|
523
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
await session.start();
|
|
527
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
528
|
+
await waitFor(() => transcribers.length > 0);
|
|
529
|
+
const transcriber = transcribers[0];
|
|
530
|
+
transcriber?.emit({ type: "partial", text: "what is the" });
|
|
531
|
+
await sleep(PAST_SILENCE_BOUNDARY_MS);
|
|
532
|
+
|
|
533
|
+
// The caller draws breath and keeps going before the provider's turn-end
|
|
534
|
+
// for the speech that boundary closed reaches the session.
|
|
535
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
536
|
+
await waitFor(
|
|
537
|
+
() => countFrames(frames, "speech_started") === 2,
|
|
538
|
+
"The resumed speech never re-opened a detector turn",
|
|
539
|
+
);
|
|
540
|
+
const receivedBeforeResume = transcriber?.received.length ?? 0;
|
|
541
|
+
|
|
542
|
+
transcriber?.emit({
|
|
543
|
+
type: "turn-end",
|
|
544
|
+
text: "what is the",
|
|
545
|
+
confidence: 0.9,
|
|
546
|
+
});
|
|
547
|
+
await flushAsyncCallbacks();
|
|
548
|
+
|
|
549
|
+
// Dropped: no boundary frame, the transcriber keeps running, no turn.
|
|
550
|
+
expect(frames.some((frame) => frame.type === "utterance_end")).toBe(false);
|
|
551
|
+
expect(transcriber?.stopped).toBe(false);
|
|
552
|
+
expect(turnCalls).toHaveLength(0);
|
|
553
|
+
|
|
554
|
+
// ...and the resumed speech still routes into the same open utterance.
|
|
555
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
556
|
+
await waitFor(
|
|
557
|
+
() => (transcriber?.received.length ?? 0) > receivedBeforeResume,
|
|
558
|
+
"The resumed speech stopped reaching the transcriber",
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
await session.close("client_end");
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("commits a fast turn-end for the still-open turn after a mid-thought pause", async () => {
|
|
565
|
+
const { frames, session, transcribers, turnCalls } = createHarness({
|
|
566
|
+
fluxConfig: FLUX_ON,
|
|
567
|
+
silenceThresholdMs: 40,
|
|
568
|
+
emitMetrics: true,
|
|
569
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
await session.start();
|
|
573
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
574
|
+
await waitFor(() => transcribers.length > 0);
|
|
575
|
+
const transcriber = transcribers[0];
|
|
576
|
+
transcriber?.startOfTurn(0);
|
|
577
|
+
transcriber?.emit({ type: "partial", text: "what is the" });
|
|
578
|
+
|
|
579
|
+
// A mid-thought pause, long enough for the local silence boundary to hand
|
|
580
|
+
// the cycle to Flux, and then the caller keeps going.
|
|
581
|
+
await sleep(PAST_SILENCE_BOUNDARY_MS);
|
|
582
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
583
|
+
await waitFor(
|
|
584
|
+
() => countFrames(frames, "speech_started") === 2,
|
|
585
|
+
"The resumed speech never re-opened a detector turn",
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
// Flux ends the turn it kept open across the pause, beating the next local
|
|
589
|
+
// silence boundary: the fast commit this feature exists for. Nothing here
|
|
590
|
+
// sleeps past that boundary, on purpose.
|
|
591
|
+
const emittedAtMs = Date.now();
|
|
592
|
+
transcriber?.endOfTurn("what is the weather today", 0);
|
|
593
|
+
await waitFor(
|
|
594
|
+
() => turnCalls.length === 1,
|
|
595
|
+
"The fast turn-end after a mid-thought pause never committed",
|
|
596
|
+
);
|
|
597
|
+
const commitLatencyMs = Date.now() - emittedAtMs;
|
|
598
|
+
|
|
599
|
+
// The fail-open budget is eotTimeoutMs (500 ms) plus the margin (1000 ms).
|
|
600
|
+
// A commit anywhere near it means the turn-end was dropped and the
|
|
601
|
+
// deadline committed in its place.
|
|
602
|
+
expect(commitLatencyMs).toBeLessThan(300);
|
|
603
|
+
expect(turnCalls[0]?.content).toBe("what is the weather today");
|
|
604
|
+
// The Flux path, not the speculative hold path the deadline falls back to.
|
|
605
|
+
expect(turnCalls[0]?.unifiedVerdict).toBeUndefined();
|
|
606
|
+
expect(frames.some((frame) => frame.type === "utterance_end")).toBe(true);
|
|
607
|
+
|
|
608
|
+
const metricsFrame = await waitForTurnMetrics(frames);
|
|
609
|
+
expect(metricsFrame.endpointDecisionSource).toBe("provider");
|
|
610
|
+
|
|
611
|
+
await session.close("client_end");
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("drops a turn-end for a turn flux has already superseded", async () => {
|
|
615
|
+
const { frames, session, transcribers, turnCalls } = createHarness({
|
|
616
|
+
fluxConfig: FLUX_ON,
|
|
617
|
+
silenceThresholdMs: 40,
|
|
618
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
await session.start();
|
|
622
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
623
|
+
await waitFor(() => transcribers.length > 0);
|
|
624
|
+
const transcriber = transcribers[0];
|
|
625
|
+
transcriber?.startOfTurn(0);
|
|
626
|
+
transcriber?.emit({ type: "partial", text: "what is the" });
|
|
627
|
+
await sleep(PAST_SILENCE_BOUNDARY_MS);
|
|
628
|
+
|
|
629
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
630
|
+
await waitFor(
|
|
631
|
+
() => countFrames(frames, "speech_started") === 2,
|
|
632
|
+
"The resumed speech never re-opened a detector turn",
|
|
633
|
+
);
|
|
634
|
+
const receivedBeforeResume = transcriber?.received.length ?? 0;
|
|
635
|
+
|
|
636
|
+
// Flux closed turn 0 and opened turn 1 for the resumed speech, so the
|
|
637
|
+
// end-of-turn for turn 0 trailing behind describes speech the caller has
|
|
638
|
+
// moved past.
|
|
639
|
+
transcriber?.startOfTurn(1);
|
|
640
|
+
transcriber?.emit({
|
|
641
|
+
type: "turn-end",
|
|
642
|
+
text: "what is the",
|
|
643
|
+
confidence: 0.9,
|
|
644
|
+
turnIndex: 0,
|
|
645
|
+
});
|
|
646
|
+
await flushAsyncCallbacks();
|
|
647
|
+
|
|
648
|
+
expect(frames.some((frame) => frame.type === "utterance_end")).toBe(false);
|
|
649
|
+
expect(transcriber?.stopped).toBe(false);
|
|
650
|
+
expect(turnCalls).toHaveLength(0);
|
|
651
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
652
|
+
await waitFor(
|
|
653
|
+
() => (transcriber?.received.length ?? 0) > receivedBeforeResume,
|
|
654
|
+
"The resumed speech stopped reaching the transcriber",
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
// Turn 1's own end-of-turn is the one that commits.
|
|
658
|
+
transcriber?.endOfTurn("what is the weather today", 1);
|
|
659
|
+
await waitFor(
|
|
660
|
+
() => turnCalls.length === 1,
|
|
661
|
+
"The superseding turn never committed",
|
|
662
|
+
);
|
|
663
|
+
expect(turnCalls[0]?.content).toBe("what is the weather today");
|
|
664
|
+
expect(turnCalls[0]?.unifiedVerdict).toBeUndefined();
|
|
665
|
+
|
|
666
|
+
await session.close("client_end");
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test("commits the resumed turn on the next turn-end after dropping a stale one", async () => {
|
|
670
|
+
const { frames, session, transcribers, turnCalls } = createHarness({
|
|
671
|
+
fluxConfig: FLUX_ON,
|
|
672
|
+
silenceThresholdMs: 40,
|
|
673
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
await session.start();
|
|
677
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
678
|
+
await waitFor(() => transcribers.length > 0);
|
|
679
|
+
await sleep(PAST_SILENCE_BOUNDARY_MS);
|
|
680
|
+
|
|
681
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
682
|
+
await waitFor(() => countFrames(frames, "speech_started") === 2);
|
|
683
|
+
transcribers[0]?.emit({
|
|
684
|
+
type: "turn-end",
|
|
685
|
+
text: "what is the",
|
|
686
|
+
confidence: 0.9,
|
|
687
|
+
});
|
|
688
|
+
await flushAsyncCallbacks();
|
|
689
|
+
expect(turnCalls).toHaveLength(0);
|
|
690
|
+
|
|
691
|
+
// The caller finishes for real: the next silence boundary re-stamps the
|
|
692
|
+
// cycle, so Flux's turn-end for the resumed speech commits it.
|
|
693
|
+
await sleep(PAST_SILENCE_BOUNDARY_MS);
|
|
694
|
+
transcribers[0]?.endOfTurn("what is the weather today");
|
|
695
|
+
|
|
696
|
+
await waitFor(
|
|
697
|
+
() => turnCalls.length === 1,
|
|
698
|
+
"The resumed turn never committed after the stale turn-end was dropped",
|
|
699
|
+
);
|
|
700
|
+
expect(turnCalls[0]?.content).toBe("what is the weather today");
|
|
701
|
+
expect(turnCalls[0]?.unifiedVerdict).toBeUndefined();
|
|
702
|
+
expect(frames.some((frame) => frame.type === "utterance_end")).toBe(true);
|
|
703
|
+
|
|
704
|
+
await session.close("client_end");
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("falls back to the deadline when nothing follows the dropped turn-end", async () => {
|
|
708
|
+
const { session, transcribers, turnCalls } = createHarness({
|
|
709
|
+
fluxConfig: FLUX_ON,
|
|
710
|
+
silenceThresholdMs: 40,
|
|
711
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
await session.start();
|
|
715
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
716
|
+
await waitFor(() => transcribers.length > 0);
|
|
717
|
+
transcribers[0]?.emit({ type: "partial", text: "are you still there" });
|
|
718
|
+
await sleep(PAST_SILENCE_BOUNDARY_MS);
|
|
719
|
+
|
|
720
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
721
|
+
transcribers[0]?.emit({
|
|
722
|
+
type: "turn-end",
|
|
723
|
+
text: "are you still there",
|
|
724
|
+
confidence: 0.9,
|
|
725
|
+
});
|
|
726
|
+
await flushAsyncCallbacks();
|
|
727
|
+
expect(turnCalls).toHaveLength(0);
|
|
728
|
+
|
|
729
|
+
// Flux says nothing more. The next silence boundary re-arms the fail-open
|
|
730
|
+
// deadline, so the utterance still commits on the silence path instead of
|
|
731
|
+
// hanging open forever.
|
|
732
|
+
await waitFor(
|
|
733
|
+
() => turnCalls.length === 1,
|
|
734
|
+
"The utterance hung after the stale turn-end was dropped",
|
|
735
|
+
);
|
|
736
|
+
expect(turnCalls[0]?.content).toBe("are you still there");
|
|
737
|
+
expect(turnCalls[0]?.unifiedVerdict).toBe(true);
|
|
738
|
+
|
|
739
|
+
await session.close("client_end");
|
|
740
|
+
}, 10_000);
|
|
741
|
+
|
|
742
|
+
for (const flagOn of [false, true]) {
|
|
743
|
+
test(`barge-in during playback fires from local VAD (flux turn end ${
|
|
744
|
+
flagOn ? "on" : "off"
|
|
745
|
+
})`, async () => {
|
|
746
|
+
const streamTtsAudio = mock(async (ttsOptions: LiveVoiceTtsOptions) => {
|
|
747
|
+
ttsOptions.onAudioChunk({
|
|
748
|
+
type: "tts_audio",
|
|
749
|
+
contentType: "audio/pcm",
|
|
750
|
+
sampleRate: SAMPLE_RATE,
|
|
751
|
+
dataBase64: Buffer.from("assistant audio").toString("base64"),
|
|
752
|
+
});
|
|
753
|
+
return {
|
|
754
|
+
provider: "fish-audio",
|
|
755
|
+
contentType: "audio/pcm",
|
|
756
|
+
sampleRate: SAMPLE_RATE,
|
|
757
|
+
chunks: 1,
|
|
758
|
+
bytes: 15,
|
|
759
|
+
} satisfies LiveVoiceTtsResult;
|
|
760
|
+
});
|
|
761
|
+
const { frames, session, transcribers } = createHarness({
|
|
762
|
+
...(flagOn ? { fluxConfig: FLUX_ON } : {}),
|
|
763
|
+
silenceThresholdMs: 10_000,
|
|
764
|
+
streamTtsAudio,
|
|
765
|
+
// The leg never completes, so the turn is still in flight when the
|
|
766
|
+
// caller speaks over it.
|
|
767
|
+
startVoiceTurn: async (turnOptions) => {
|
|
768
|
+
turnOptions.callbacks?.assistant_text_delta?.(
|
|
769
|
+
makeTextDelta("Let me tell you a long story."),
|
|
770
|
+
);
|
|
771
|
+
return { turnId: "bridge-turn-1", abort: mock() };
|
|
772
|
+
},
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
await session.start();
|
|
776
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
777
|
+
await waitFor(() => transcribers.length > 0);
|
|
778
|
+
if (flagOn) {
|
|
779
|
+
transcribers[0]?.endOfTurn("tell me a story");
|
|
780
|
+
} else {
|
|
781
|
+
transcribers[0]?.emit({ type: "final", text: "tell me a story" });
|
|
782
|
+
await session.handleClientFrame({ type: "ptt_release" });
|
|
783
|
+
}
|
|
784
|
+
await waitFor(() => frames.some((frame) => frame.type === "thinking"));
|
|
785
|
+
|
|
786
|
+
// Local energy detection, not the provider's turn model, is what
|
|
787
|
+
// interrupts the assistant.
|
|
788
|
+
await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
|
|
789
|
+
await waitFor(
|
|
790
|
+
() => frames.some((frame) => frame.type === "speech_started"),
|
|
791
|
+
"Local VAD barge-in never fired",
|
|
792
|
+
);
|
|
793
|
+
await waitFor(() =>
|
|
794
|
+
frames.some((frame) => frame.type === "turn_cancelled"),
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
await session.close("client_end");
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* The window between `ready` and the resolved transcriber. `start()` arms the
|
|
804
|
+
* utterance in the background so the client's mic acquisition overlaps the STT
|
|
805
|
+
* handshake, which means a fast caller can speak AND fall silent before the
|
|
806
|
+
* dial answers. These tests seed `services.stt.provider` for real, because the
|
|
807
|
+
* latch is armed from the configured provider before the dial and reconciled
|
|
808
|
+
* with the resolved one after it.
|
|
809
|
+
*/
|
|
810
|
+
describe("LiveVoiceSession Flux end-of-turn during the STT dial", () => {
|
|
811
|
+
beforeEach(() => {
|
|
812
|
+
setConfig("services", {
|
|
813
|
+
stt: { provider: "deepgram-flux", providers: {} },
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
afterEach(() => {
|
|
818
|
+
setConfig("services", { stt: { provider: "deepgram", providers: {} } });
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
test("waits for turn-end when the boundary lands before the dial resolves", async () => {
|
|
822
|
+
const gate = createDialGate();
|
|
823
|
+
const { frames, session, transcribers, turnCalls } = createHarness({
|
|
824
|
+
fluxConfig: FLUX_ON,
|
|
825
|
+
silenceThresholdMs: 40,
|
|
826
|
+
resolveGate: gate.promise,
|
|
827
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
await session.start();
|
|
831
|
+
expect(transcribers).toHaveLength(0);
|
|
832
|
+
|
|
833
|
+
// The caller speaks and falls silent entirely inside the handshake
|
|
834
|
+
// window, so the whole first turn is decided before any provider answers.
|
|
835
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
836
|
+
await sleep(PAST_SILENCE_BOUNDARY_MS);
|
|
837
|
+
|
|
838
|
+
// The silence boundary passed and committed nothing: the latch was seeded
|
|
839
|
+
// from the configured provider, so Flux owns this boundary like any other.
|
|
840
|
+
expect(transcribers).toHaveLength(0);
|
|
841
|
+
expect(countFrames(frames, "utterance_end")).toBe(0);
|
|
842
|
+
expect(turnCalls).toHaveLength(0);
|
|
843
|
+
|
|
844
|
+
gate.open();
|
|
845
|
+
await waitFor(() => transcribers.length > 0);
|
|
846
|
+
transcribers[0]?.endOfTurn("what is the weather");
|
|
847
|
+
|
|
848
|
+
await waitFor(
|
|
849
|
+
() => turnCalls.length === 1,
|
|
850
|
+
"The first utterance never committed on the Flux end-of-turn",
|
|
851
|
+
);
|
|
852
|
+
// Committed by Flux, not by the silence path: non-speculative dispatch.
|
|
853
|
+
expect(turnCalls[0]?.content).toBe("what is the weather");
|
|
854
|
+
expect(turnCalls[0]?.unifiedVerdict).toBeUndefined();
|
|
855
|
+
expect(countFrames(frames, "utterance_end")).toBe(1);
|
|
856
|
+
|
|
857
|
+
await session.close("client_end");
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
test("unwinds the seeded latch when the dial resolves to another provider", async () => {
|
|
861
|
+
const gate = createDialGate();
|
|
862
|
+
const { frames, session, transcribers, turnCalls } = createHarness({
|
|
863
|
+
// Config names Flux, so the latch is seeded, but the dial answers with
|
|
864
|
+
// another provider: a fallback, or a resolver reading a config the
|
|
865
|
+
// session no longer matches. That provider never sends an end-of-turn.
|
|
866
|
+
providerId: "deepgram",
|
|
867
|
+
// Far enough out that the fail-open deadline alone cannot be what
|
|
868
|
+
// releases this utterance inside the test's budget.
|
|
869
|
+
fluxConfig: { turnEnd: { enabled: true }, eotTimeoutMs: 30_000 },
|
|
870
|
+
silenceThresholdMs: 40,
|
|
871
|
+
resolveGate: gate.promise,
|
|
872
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
await session.start();
|
|
876
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
877
|
+
await sleep(PAST_SILENCE_BOUNDARY_MS);
|
|
878
|
+
// Deferred under the seeded latch, exactly as a real Flux session would.
|
|
879
|
+
expect(countFrames(frames, "utterance_end")).toBe(0);
|
|
880
|
+
|
|
881
|
+
gate.open();
|
|
882
|
+
await waitFor(() => transcribers.length > 0);
|
|
883
|
+
|
|
884
|
+
// The deferred boundary is replayed as soon as the dial disproves the
|
|
885
|
+
// seed, instead of being stranded on a deadline 30 s away.
|
|
886
|
+
await waitFor(
|
|
887
|
+
() => countFrames(frames, "utterance_end") === 1,
|
|
888
|
+
"The utterance deferred under the seeded latch never released",
|
|
889
|
+
);
|
|
890
|
+
await waitFor(
|
|
891
|
+
() => transcribers[0]?.stopped === true,
|
|
892
|
+
"The utterance never finished releasing",
|
|
893
|
+
);
|
|
894
|
+
// ...and nothing routed through the Flux commit path.
|
|
895
|
+
expect(turnCalls.every((call) => call.unifiedVerdict !== undefined)).toBe(
|
|
896
|
+
true,
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
await session.close("client_end");
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test("never seeds the latch when the flag is off", async () => {
|
|
903
|
+
const gate = createDialGate();
|
|
904
|
+
const { frames, session, transcribers, turnCalls } = createHarness({
|
|
905
|
+
// No fluxConfig: `turnEnd.enabled` keeps its schema default of false
|
|
906
|
+
// while config still names deepgram-flux as the provider.
|
|
907
|
+
silenceThresholdMs: 40,
|
|
908
|
+
resolveGate: gate.promise,
|
|
909
|
+
startVoiceTurn: autoCompletingTurn(),
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
await session.start();
|
|
913
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
914
|
+
|
|
915
|
+
// Unchanged from today: the silence boundary releases during the dial.
|
|
916
|
+
await waitFor(
|
|
917
|
+
() => countFrames(frames, "utterance_end") === 1,
|
|
918
|
+
"The flag-off silence boundary stopped releasing during the dial",
|
|
919
|
+
);
|
|
920
|
+
expect(turnCalls).toHaveLength(0);
|
|
921
|
+
|
|
922
|
+
gate.open();
|
|
923
|
+
await waitFor(() => transcribers.length > 0);
|
|
924
|
+
transcribers[0]?.endOfTurn("hello there");
|
|
925
|
+
await flushAsyncCallbacks();
|
|
926
|
+
|
|
927
|
+
// The turn-end commits nothing on top of the boundary that already ran.
|
|
928
|
+
expect(countFrames(frames, "utterance_end")).toBe(1);
|
|
929
|
+
|
|
930
|
+
await session.close("client_end");
|
|
931
|
+
});
|
|
932
|
+
});
|