fix(web): deliver voice session bootstrap via contextual updates (#1344)

ElevenLabs only passed bootstrap context through dynamicVariables without
a matching {{initialConversationContext}} prompt placeholder, so Brief me
connected with no session history. Stream deferred chunks then push bootstrap
on all backends; add the placeholder for newly created ConvAI agents.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-08-03 18:01:32 +08:00
committed by GitHub
co-authored by Cursor
parent 82639c66f8
commit ae671c123b
7 changed files with 103 additions and 30 deletions
+8
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test'
import {
buildVoiceAgentConfig,
listConfiguredTranscriptionProviders,
listConfiguredVoiceBackends,
resolveEffectiveVoiceBackend,
@@ -42,6 +43,13 @@ describe('listConfiguredVoiceBackends', () => {
})
})
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({
+12 -1
View File
@@ -95,6 +95,17 @@ no ${name} equivalent.`
/** ElevenLabs first message — language controlled by ElevenLabs language field */
export const VOICE_FIRST_MESSAGE = "Hey! Hapi here — what can I help you with?"
/** Appended to ElevenLabs ConvAI agent prompt; filled via startSession dynamicVariables. */
export const VOICE_ELEVENLABS_SESSION_CONTEXT_BLOCK = `
# Active session at connect
The user connected from a live coding session. Snapshot at connect (may be empty if unavailable):
{{initialConversationContext}}
Use this plus any later context updates when briefing or routing.`
export const VOICE_TOOLS = [
{
type: 'client' as const,
@@ -191,7 +202,7 @@ export function buildVoiceAgentConfig(): VoiceAgentConfig {
first_message: VOICE_FIRST_MESSAGE,
language: 'en',
prompt: {
prompt: VOICE_SYSTEM_PROMPT,
prompt: VOICE_SYSTEM_PROMPT + VOICE_ELEVENLABS_SESSION_CONTEXT_BLOCK,
llm: 'gemini-2.5-flash',
temperature: 0.7,
max_tokens: 1024,
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, test, vi } from 'vitest'
import { deliverVoiceSessionContextAfterConnect } from './voiceContextStream'
describe('deliverVoiceSessionContextAfterConnect', () => {
test('streams older chunks then bootstrap context', async () => {
const sent: string[] = []
const sendChunk = vi.fn((chunk: string) => {
sent.push(chunk)
})
await deliverVoiceSessionContextAfterConnect({
streamContextChunks: ['older-a', 'older-b'],
initialContext: 'bootstrap recent',
sendChunk
})
expect(sent).toEqual(['older-a', 'older-b', 'bootstrap recent'])
})
test('sends bootstrap even when there are no stream chunks', async () => {
const sent: string[] = []
await deliverVoiceSessionContextAfterConnect({
initialContext: ' session header only ',
sendChunk: (chunk) => sent.push(chunk)
})
expect(sent).toEqual(['session header only'])
})
test('skips empty bootstrap', async () => {
const sent: string[] = []
await deliverVoiceSessionContextAfterConnect({
streamContextChunks: ['chunk'],
initialContext: ' ',
sendChunk: (chunk) => sent.push(chunk)
})
expect(sent).toEqual(['chunk'])
})
})
+23
View File
@@ -32,6 +32,29 @@ export async function streamDeferredVoiceContext(
}
}
/**
* Stream older history, then deliver bootstrap context — same order on every backend.
* ElevenLabs dynamicVariables alone are insufficient unless the agent prompt references
* {{initialConversationContext}}; contextual updates are the reliable delivery path.
*/
export async function deliverVoiceSessionContextAfterConnect(options: {
streamContextChunks?: string[]
initialContext?: string
sendChunk: (chunk: string) => void
streamDelayMs?: number
}): Promise<void> {
const streamChunks = options.streamContextChunks ?? []
if (streamChunks.length > 0) {
await streamDeferredVoiceContext(options.sendChunk, streamChunks, {
delayMs: options.streamDelayMs
})
}
const bootstrap = options.initialContext?.trim()
if (bootstrap) {
options.sendChunk(bootstrap)
}
}
export function isVoiceProactiveSummaryEnabled(): boolean {
return localStorage.getItem('hapi-voice-proactive') === 'true'
}
+6 -11
View File
@@ -13,7 +13,7 @@ import {
truncatePromptForProxy
} from '@/lib/voicePersonalitySession'
import { loadVoicePersonalityFromStorage } from '@/hooks/useVoicePersonality'
import { isVoiceProactiveSummaryEnabled, streamDeferredVoiceContext } from '@/lib/voiceContextStream'
import { deliverVoiceSessionContextAfterConnect, isVoiceProactiveSummaryEnabled } from '@/lib/voiceContextStream'
import { readStoredVoiceSelection } from '@/lib/voicePickerPreferences'
import type { VoiceSession, VoiceSessionConfig, StatusCallback } from './types'
import type { ApiClient } from '@/api/client'
@@ -200,24 +200,19 @@ class GeminiLiveVoiceSessionImpl implements VoiceSession {
}
state.statusCallback?.('connected')
await streamDeferredVoiceContext(
(chunk) => sendClientContent(`[Context] ${chunk}`, false),
config.streamContextChunks ?? []
)
await deliverVoiceSessionContextAfterConnect({
streamContextChunks: config.streamContextChunks,
initialContext: config.initialContext,
sendChunk: (chunk) => sendClientContent(`[Context] ${chunk}`, false)
})
const proactive = isVoiceProactiveSummaryEnabled()
if (proactive) {
if (config.initialContext?.trim()) {
sendClientContent(`[Context] ${config.initialContext}`, false)
}
sendClientContent(
'[Summarize] Based on all session context above, give the user a brief spoken summary of what the coding agent has been doing, then wait.',
true
)
} else {
if (config.initialContext?.trim()) {
sendClientContent(`[Context] ${config.initialContext}`, false)
}
sendClientContent(
'[Greet the user. Say a brief hello and invite them to speak. Do not mention Gemini or any model name.]',
true
+6 -11
View File
@@ -11,7 +11,7 @@ import {
encodeVoiceSystemPromptForProxy,
truncatePromptForProxy
} from '@/lib/voicePersonalitySession'
import { isVoiceProactiveSummaryEnabled, streamDeferredVoiceContext } from '@/lib/voiceContextStream'
import { deliverVoiceSessionContextAfterConnect, isVoiceProactiveSummaryEnabled } from '@/lib/voiceContextStream'
import { readStoredVoiceSelection } from '@/lib/voicePickerPreferences'
import type { VoiceSession, VoiceSessionConfig, StatusCallback } from './types'
import type { ApiClient } from '@/api/client'
@@ -197,23 +197,18 @@ class QwenVoiceSessionImpl implements VoiceSession {
}
state.statusCallback?.('connected')
await streamDeferredVoiceContext(
(chunk) => this.sendContextualUpdate(chunk),
config.streamContextChunks ?? []
)
await deliverVoiceSessionContextAfterConnect({
streamContextChunks: config.streamContextChunks,
initialContext: config.initialContext,
sendChunk: (chunk) => this.sendContextualUpdate(chunk)
})
const proactive = isVoiceProactiveSummaryEnabled()
if (proactive) {
if (config.initialContext?.trim()) {
this.sendContextualUpdate(config.initialContext)
}
this.sendTextMessage(
'Based on all session context above, give me a brief spoken summary of what the coding agent has been doing, then wait.'
)
} else {
if (config.initialContext?.trim()) {
this.sendContextualUpdate(config.initialContext)
}
this.sendTextMessage(
'[Greet the user. Say a brief hello and invite them to speak. Do not mention Qwen or any model name.]'
)
+6 -7
View File
@@ -4,7 +4,7 @@ import { registerVoiceSession, resetRealtimeSessionState } from './RealtimeSessi
import { realtimeClientTools, registerSessionStore } from './realtimeClientTools'
import { fetchVoiceToken } from '@/api/voice'
import { buildElevenLabsSessionOverrides, capElevenLabsInitialContext } from '@/lib/voicePersonalitySession'
import { isVoiceProactiveSummaryEnabled, streamDeferredVoiceContext } from '@/lib/voiceContextStream'
import { deliverVoiceSessionContextAfterConnect, isVoiceProactiveSummaryEnabled } from '@/lib/voiceContextStream'
import { readStoredVoiceSelection } from '@/lib/voicePickerPreferences'
import type { VoiceSession, VoiceSessionConfig, ConversationStatus, StatusCallback } from './types'
import type { ApiClient } from '@/api/client'
@@ -101,12 +101,11 @@ class RealtimeVoiceSessionImpl implements VoiceSession {
console.log('[Voice] Started conversation with ID:', conversationId)
}
if (config.streamContextChunks?.length) {
await streamDeferredVoiceContext(
(chunk) => conversationInstance?.sendContextualUpdate(chunk),
config.streamContextChunks
)
}
await deliverVoiceSessionContextAfterConnect({
streamContextChunks: config.streamContextChunks,
initialContext: config.initialContext,
sendChunk: (chunk) => conversationInstance?.sendContextualUpdate(chunk)
})
if (isVoiceProactiveSummaryEnabled()) {
this.sendTextMessage(