Files
hapi/shared/src/voice.backends.test.ts
T
7b00df458c fix(voice): configurable ElevenLabs max conversation duration (#2017)
* feat(voice): configurable ElevenLabs max conversation duration

Auto-created ConvAI agents never set conversation.max_duration_seconds, so
ElevenLabs applied its 600s default and cut voice conversations off.

Add ELEVENLABS_MAX_DURATION_SECONDS (default 1800, validated 60-7200). The cap
is applied when the hub creates an agent and reconciled onto existing
hub-managed agents (found by name) once per process. Operator-supplied agents
(ELEVENLABS_AGENT_ID / voice map / customAgentId) keep their own cap. Invalid
values fail the token request with a clear message instead of an API 422.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* feat(voice): Settings UI for ElevenLabs max conversation duration

Extend the hub-owner credentials panel (Settings -> Voice -> manage
providers) with a numeric field for ELEVENLABS_MAX_DURATION_SECONDS,
reusing the same env-wins-over-Settings overlay that already backs the
ElevenLabs API key field. A Settings save takes effect on the very next
token request in the same process; the env var still requires a restart.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(voice): reconcile max duration on cached agents, not just once per process

Codex review on heavygee/hapi#230 caught this: getOrCreateAgentIdForVoice's
agentIdCache short-circuited before ever reaching ensureAgentOverrides, and
ensureAgentOverrides's overridesEnsuredAgents gate was a one-time-forever
marker that didn't account for the configured duration changing after the
first reconciliation. Together, any voice already used once in the hub
process would keep its old cap until a restart, contradicting the Settings
UI's 'takes effect immediately' claim.

Track the last-applied max duration per agent and compare it against the
live setting on every call, including agentIdCache hits, so a Settings
change reaches an already-cached agent on its very next token request.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(voice): address tiann/hapi#2017 review findings

- Fix TS2769 typecheck failure: test assertions casting the credentials
  response narrowed the type to omit 'editable', which upstream CI's
  bun typecheck catches and this fork's (currently broken) CI did not.
- An invalid ELEVENLABS_MAX_DURATION_SECONDS env value threw out of
  buildMaxDurationStatus(), which is shared by every provider's
  GET/PUT credentials status — breaking visibility into OpenAI/Deepgram/
  Groq settings too, not just ElevenLabs. The token-issuance hot path
  (getConfiguredElevenLabsMaxDurationSeconds) still throws there by
  design; only the status/update endpoints now degrade gracefully.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(voice): interpolate duration range in the Settings hint

Nit from tiann/hapi#2017 follow-up review: the hint under the max-duration
field rendered the literal '{min}-{max}' placeholders instead of '60-7200'.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:42:01 +01:00

121 lines
4.6 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import {
buildVoiceAgentConfig,
listConfiguredTranscriptionProviders,
listConfiguredVoiceBackends,
resolveEffectiveVoiceBackend,
resolveVoiceMaxDurationSeconds,
resolveHubVoiceBackend
} from './voice'
describe('listConfiguredTranscriptionProviders', () => {
test('returns configured providers with honest mode capabilities', () => {
expect(listConfiguredTranscriptionProviders({
OPENAI_API_KEY: 'openai',
ELEVENLABS_API_KEY: 'elevenlabs',
DEEPGRAM_API_KEY: 'deepgram',
GROQ_API_KEY: 'groq',
TRANSCRIPTION_BASE_URL: 'http://localhost:8000/v1',
TRANSCRIPTION_MODEL: 'whisper-large-v3'
})).toEqual([
{ id: 'openai', label: 'OpenAI', modes: ['standard', 'realtime'] },
{ id: 'elevenlabs', label: 'ElevenLabs', modes: ['standard', 'realtime'] },
{ id: 'deepgram', label: 'Deepgram', modes: ['standard', 'realtime'] },
{ id: 'groq', label: 'Groq', modes: ['standard'] },
{ id: 'openai-compatible', label: 'OpenAI-compatible / local', modes: ['standard'] }
])
})
test('does not advertise incomplete or missing configuration', () => {
expect(listConfiguredTranscriptionProviders({ TRANSCRIPTION_BASE_URL: 'http://localhost:8000/v1' })).toEqual([])
})
})
describe('listConfiguredVoiceBackends', () => {
test('returns only backends with API keys', () => {
const backends = listConfiguredVoiceBackends({
ELEVENLABS_API_KEY: 'el',
GEMINI_API_KEY: 'gm',
DASHSCOPE_API_KEY: 'qw'
})
expect(backends).toEqual(['elevenlabs', 'gemini-live', 'qwen-realtime'])
})
test('returns empty when no keys configured', () => {
expect(listConfiguredVoiceBackends({})).toEqual([])
})
})
describe('buildVoiceAgentConfig', () => {
test('includes ElevenLabs session context placeholder for dynamicVariables', () => {
const prompt = buildVoiceAgentConfig().conversation_config.agent.prompt.prompt
expect(prompt).toContain('{{initialConversationContext}}')
})
})
describe('resolveHubVoiceBackend', () => {
test('uses VOICE_BACKEND when that backend is configured', () => {
const backend = resolveHubVoiceBackend({
VOICE_BACKEND: 'gemini-live',
GEMINI_API_KEY: 'gm',
ELEVENLABS_API_KEY: 'el'
})
expect(backend).toBe('gemini-live')
})
test('falls back to first configured when VOICE_BACKEND unavailable', () => {
const backend = resolveHubVoiceBackend({
VOICE_BACKEND: 'qwen-realtime',
ELEVENLABS_API_KEY: 'el'
})
expect(backend).toBe('elevenlabs')
})
test('returns null when no backends configured', () => {
expect(resolveHubVoiceBackend({})).toBeNull()
})
})
describe('resolveEffectiveVoiceBackend', () => {
const configured = ['elevenlabs', 'gemini-live'] as const
test('prefers stored preference when configured', () => {
expect(resolveEffectiveVoiceBackend(configured, 'gemini-live', 'elevenlabs')).toBe('elevenlabs')
})
test('uses hub default when preference missing or invalid', () => {
expect(resolveEffectiveVoiceBackend(configured, 'gemini-live', null)).toBe('gemini-live')
expect(resolveEffectiveVoiceBackend(configured, 'gemini-live', 'qwen-realtime')).toBe('gemini-live')
})
test('returns null when no backends configured', () => {
expect(resolveEffectiveVoiceBackend([], null, null)).toBeNull()
})
})
describe('resolveVoiceMaxDurationSeconds', () => {
test('defaults to 1800s when unset or blank', () => {
expect(resolveVoiceMaxDurationSeconds(undefined)).toBe(1800)
expect(resolveVoiceMaxDurationSeconds(' ')).toBe(1800)
})
test('accepts the inclusive 60..7200 range', () => {
expect(resolveVoiceMaxDurationSeconds('60')).toBe(60)
expect(resolveVoiceMaxDurationSeconds(' 7200 ')).toBe(7200)
})
test('rejects out-of-range and non-integer values', () => {
for (const bad of ['59', '7201', '0', '-1', 'abc', '1800.5']) {
expect(() => resolveVoiceMaxDurationSeconds(bad)).toThrow(/between 60 and 7200/)
}
})
})
describe('buildVoiceAgentConfig max duration', () => {
test('omits conversation cap unless requested, includes it when given', () => {
expect(buildVoiceAgentConfig().conversation_config.conversation).toBeUndefined()
expect(buildVoiceAgentConfig({ maxDurationSeconds: 900 }).conversation_config.conversation)
.toEqual({ max_duration_seconds: 900 })
})
})