fix(eve): track realtime in-flight state for all responses; fix voice itemId

Set responseInFlightRef on every realtime response-created, not only ones solicited via speak(), so a server-VAD auto-response suppresses user-transcript enqueuing for its lifetime and its echoed audio can't start a spurious Eve turn. Removes the now-redundant expectSpeechResponseRef.

Thread each utterance's itemId through the turn queue so onTranscript reports the correct item when multiple transcripts finalize before the queue drains (was reading a shared latest-id ref).

Docs: scope the channel explicitly to Vercel AI Gateway (eve/channels/vercel/speech), document the shipped browser<->Gateway audio / browser<->Eve durable-turn topology, note the future Gateway<->Eve WS control plane as roadmap only, add the sessionConfig.instructions caveat, and fix stale gallery copy that referenced the removed /turn route.
This commit is contained in:
Kevin Dawkins
2026-06-19 11:29:21 -07:00
parent bfc8eda340
commit 2cbb745d9a
4 changed files with 94 additions and 20 deletions
+1 -1
View File
@@ -334,7 +334,7 @@ export function ComposerActions() {
return <button onClick={() => (active ? voice.stop() : void voice.start())}>Talk</button>; return <button onClick={() => (active ? voice.stop() : void voice.start())}>Talk</button>;
} }
\`\`\``, \`\`\``,
configure: `Set \`AI_GATEWAY_API_KEY\` so the setup route can mint short-lived AI Gateway realtime client secrets. The browser keeps the realtime audio socket open, while each finalized utterance calls Eve's turn route. Eve binds the voice session id to the authenticated principal before deriving the durable continuation token.`, configure: `Set \`AI_GATEWAY_API_KEY\` so the setup route can mint short-lived AI Gateway realtime client secrets. The browser keeps the realtime audio socket open, while each finalized utterance runs as an ordinary durable turn through the existing \`/eve/v1/session\` routes and event stream. The voice session id is a client-visible correlation id only; principal binding comes from normal session-route auth.`,
}, },
}; };
+17 -4
View File
@@ -1,9 +1,16 @@
--- ---
title: "Realtime Speech" title: "Realtime Speech (Vercel AI Gateway)"
description: "Add long-lived browser speech sessions that call your Eve agent through normal durable turns." description: "Add a browser microphone backed by Vercel AI Gateway realtime audio; finalized utterances run as normal durable Eve turns."
--- ---
Realtime Speech adds a microphone surface for agents without turning Eve into a long-running audio runtime. The browser keeps a realtime audio WebSocket open to AI Gateway. Eve serves a setup route that authenticates the caller and mints a short-lived Gateway client secret, then maps each finalized utterance into one normal durable agent turn through the existing session API. Realtime Speech adds a microphone surface for agents without turning Eve into a long-running audio runtime.
This channel is specific to **Vercel AI Gateway** realtime audio and is exported from `eve/channels/vercel/speech`. It is not a provider-agnostic speech API. The shipped topology is:
- **Audio:** browser ↔ AI Gateway realtime WebSocket (Eve is never in the audio path).
- **Turns:** browser/app ↔ Eve `/eve/v1/session` (+ `/stream`) as ordinary durable turns.
Eve serves a setup route that authenticates the caller and mints a short-lived Gateway client secret for the browser audio socket; each finalized utterance then becomes one normal durable agent turn through the existing session API.
`useEveVoice` is audio-first by default: it requests audio output from the realtime model and uses transcription events for visible text. Override `sessionConfig.outputModalities` only if you intentionally want a text-only or provider-specific realtime mode. `useEveVoice` is audio-first by default: it requests audio output from the realtime model and uses transcription events for visible text. Override `sessionConfig.outputModalities` only if you intentionally want a text-only or provider-specific realtime mode.
@@ -75,7 +82,7 @@ console.log(reply.message);
- The client consumes the session event stream (`GET /eve/v1/session/:sessionId/stream`) and, on a non-tool-call `message.completed`, sends that text back to the realtime session for audio playback. - The client consumes the session event stream (`GET /eve/v1/session/:sessionId/stream`) and, on a non-tool-call `message.completed`, sends that text back to the realtime session for audio playback.
- The durable continuation token returned by the session route keeps the same Eve conversation across utterances, advancing the stream cursor each turn. - The durable continuation token returned by the session route keeps the same Eve conversation across utterances, advancing the stream cursor each turn.
No Eve HTTP request blocks for a full model turn: the setup and turn POSTs return right away, and replies arrive over the event stream. The speech transport can stay open for many utterances. Eve still sees discrete durable turns and parks between them, so history, tools, compaction, auth, and instrumentation behave the same as other channels. No Eve HTTP request blocks for a full model turn: the setup POST and each session turn POST return right away, and replies arrive over the event stream. The speech transport can stay open for many utterances. Eve still sees discrete durable turns and parks between them, so history, tools, compaction, auth, and instrumentation behave the same as other channels.
Eve is the durable source of truth for the conversation: transcripts enter Eve as user turns, and spoken replies are readback of Eve output. Realtime provider suppression is still client/provider mediated, so do not treat the realtime model as a security boundary for policy decisions. Eve is the durable source of truth for the conversation: transcripts enter Eve as user turns, and spoken replies are readback of Eve output. Realtime provider suppression is still client/provider mediated, so do not treat the realtime model as a security boundary for policy decisions.
@@ -92,3 +99,9 @@ export default realtimeSpeechChannel({
`useEveVoice` defaults to the `/eve/v1/realtime-speech/setup` route and same-origin `/eve/v1/session` routes. Override `setupUrl` only if you changed the channel `basePath`, and pass `host`, `auth`, `headers`, `client`, or `session` to run turns against a custom origin or a session you already manage (for example one shared with `useEveAgent`). `useEveVoice` defaults to the `/eve/v1/realtime-speech/setup` route and same-origin `/eve/v1/session` routes. Override `setupUrl` only if you changed the channel `basePath`, and pass `host`, `auth`, `headers`, `client`, or `session` to run turns against a custom origin or a session you already manage (for example one shared with `useEveAgent`).
The default client session config uses `outputModalities: ["audio"]`, `inputAudioTranscription: {}`, and `outputAudioTranscription: {}`. This keeps the speech UX compatible with realtime providers that reject mixed `audio` + `text` output modalities while still letting the UI observe transcripts. The default client session config uses `outputModalities: ["audio"]`, `inputAudioTranscription: {}`, and `outputAudioTranscription: {}`. This keeps the speech UX compatible with realtime providers that reject mixed `audio` + `text` output modalities while still letting the UI observe transcripts.
`sessionConfig` is merged over the defaults, so passing `instructions` replaces the built-in speech-adapter prompt that drives reply playback (it tells the model to speak only the text after the `EVE_SPEAK:` marker). If you override `instructions`, keep that marker behavior or Eve's replies will not be spoken.
## Roadmap
In the shipped mode above, the client (or app) owns turn timing. A future **server-owned control mode** is planned but **not yet available**: AI Gateway will dial an Eve `WS()` control route with a signed, short-lived control token. Eve will verify the token and own turn coordination (debounce, barge-in, enforced ears-only), receiving final-transcript and lifecycle packets and streaming `response.delta` / `response.done` back as semantic packets for Gateway to inject into the provider's TTS. Audio still never flows through Eve. Until that ships, use the durable-session path described here.
+67
View File
@@ -240,6 +240,73 @@ describe("useEveVoice", () => {
expect(realtimeState.requestResponse).not.toHaveBeenCalled(); expect(realtimeState.requestResponse).not.toHaveBeenCalled();
}); });
it("suppresses transcripts during an unsolicited model response", async () => {
const fetch = vi.fn();
vi.stubGlobal("fetch", fetch);
const { useEveVoice } = await import("#react/voice.js");
function TestComponent() {
useEveVoice({ voiceSessionId: "voice-1" });
return null;
}
act(() => {
create(createElement(TestComponent));
});
// A server-VAD auto-response we never solicited still marks a response in
// flight, so its echoed-audio transcript must not start an Eve turn.
realtimeOptions[0].onEvent({ raw: {}, responseId: "auto-1", type: "response-created" });
realtimeOptions[0].onEvent({
itemId: "echo-1",
raw: {},
transcript: "model echo",
type: "input-transcription-completed",
});
expect(fetch).not.toHaveBeenCalled();
});
it("passes each transcript's own itemId to onTranscript", async () => {
const { useEveVoice } = await import("#react/voice.js");
const seen: Array<{ itemId: string; transcript: string }> = [];
function TestComponent() {
useEveVoice({
voiceSessionId: "voice-1",
onTranscript: ({ itemId, transcript }) => {
seen.push({ itemId, transcript });
},
});
return null;
}
act(() => {
create(createElement(TestComponent));
});
// Both finalize before the serialized turn queue drains; each turn must
// report the itemId captured at enqueue time, not the latest one.
realtimeOptions[0].onEvent({
itemId: "item-1",
raw: {},
transcript: "first",
type: "input-transcription-completed",
});
realtimeOptions[0].onEvent({
itemId: "item-2",
raw: {},
transcript: "second",
type: "input-transcription-completed",
});
await vi.waitFor(() => expect(seen).toHaveLength(2));
expect(seen).toEqual([
{ itemId: "item-1", transcript: "first" },
{ itemId: "item-2", transcript: "second" },
]);
});
it("suppresses transcriptions that arrive while the Eve reply is speaking", async () => { it("suppresses transcriptions that arrive while the Eve reply is speaking", async () => {
const fetch = sessionFetchMock([ const fetch = sessionFetchMock([
{ sessionId: "session-1", events: [completedMessageEvent("Agent reply"), waiting()] }, { sessionId: "session-1", events: [completedMessageEvent("Agent reply"), waiting()] },
+9 -15
View File
@@ -252,7 +252,6 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
const [lastReply, setLastReply] = useState<string | undefined>(undefined); const [lastReply, setLastReply] = useState<string | undefined>(undefined);
const [sessionId, setSessionId] = useState<string | undefined>(session.state.sessionId); const [sessionId, setSessionId] = useState<string | undefined>(session.state.sessionId);
const [streamIndex, setStreamIndex] = useState(session.state.streamIndex); const [streamIndex, setStreamIndex] = useState(session.state.streamIndex);
const expectSpeechResponseRef = useRef(false);
const ignoreInputUntilRef = useRef(0); const ignoreInputUntilRef = useRef(0);
const processedInputItemsRef = useRef(new Set<string>()); const processedInputItemsRef = useRef(new Set<string>());
const requestResponseRef = useRef<((options?: { modalities?: string[] }) => void) | undefined>( const requestResponseRef = useRef<((options?: { modalities?: string[] }) => void) | undefined>(
@@ -305,7 +304,6 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
const trimmed = text.trim(); const trimmed = text.trim();
if (trimmed.length === 0) return; if (trimmed.length === 0) return;
expectSpeechResponseRef.current = true;
sendEventRef.current?.({ sendEventRef.current?.({
type: "conversation-item-create", type: "conversation-item-create",
item: { item: {
@@ -325,10 +323,10 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
}, [options.fallbackReply, speakEveReply]); }, [options.fallbackReply, speakEveReply]);
const runEveTurn = useCallback( const runEveTurn = useCallback(
async (message: string) => { async (message: string, itemId: string) => {
if (options.onTranscript !== undefined) { if (options.onTranscript !== undefined) {
const reply = await options.onTranscript({ const reply = await options.onTranscript({
itemId: latestInputItemIdRef.current ?? "", itemId,
transcript: message, transcript: message,
voiceSessionId, voiceSessionId,
}); });
@@ -400,12 +398,11 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
); );
const turnQueueRef = useRef(Promise.resolve()); const turnQueueRef = useRef(Promise.resolve());
const latestInputItemIdRef = useRef<string | undefined>(undefined);
const enqueueEveTurn = useCallback( const enqueueEveTurn = useCallback(
(message: string) => { (message: string, itemId: string) => {
turnQueueRef.current = turnQueueRef.current turnQueueRef.current = turnQueueRef.current
.catch(() => undefined) .catch(() => undefined)
.then(() => runEveTurn(message)) .then(() => runEveTurn(message, itemId))
.catch((cause) => { .catch((cause) => {
const nextError = cause instanceof Error ? cause : new Error(String(cause)); const nextError = cause instanceof Error ? cause : new Error(String(cause));
handleError(nextError); handleError(nextError);
@@ -418,16 +415,15 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
(event: Experimental_RealtimeServerEvent) => { (event: Experimental_RealtimeServerEvent) => {
switch (event.type) { switch (event.type) {
case "response-created": case "response-created":
if (!expectSpeechResponseRef.current) { // Suppress user transcripts for the lifetime of ANY model response,
break; // not only ones solicited via speak(). A server-VAD auto-response
} // would otherwise play with no in-flight flag set, and its own audio
expectSpeechResponseRef.current = false; // could be transcribed back and enqueued as a spurious user turn.
responseInFlightRef.current = true; responseInFlightRef.current = true;
break; break;
case "response-done": case "response-done":
case "error": case "error":
responseInFlightRef.current = false; responseInFlightRef.current = false;
expectSpeechResponseRef.current = false;
ignoreInputUntilRef.current = Date.now() + ECHO_SUPPRESSION_MS; ignoreInputUntilRef.current = Date.now() + ECHO_SUPPRESSION_MS;
break; break;
case "speech-started": case "speech-started":
@@ -447,7 +443,6 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
const oldest = processedInputItemsRef.current.values().next().value; const oldest = processedInputItemsRef.current.values().next().value;
if (oldest !== undefined) processedInputItemsRef.current.delete(oldest); if (oldest !== undefined) processedInputItemsRef.current.delete(oldest);
} }
latestInputItemIdRef.current = event.itemId;
const transcript = event.transcript.trim(); const transcript = event.transcript.trim();
if (transcript.length === 0) { if (transcript.length === 0) {
break; break;
@@ -455,7 +450,7 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
if (responseInFlightRef.current || Date.now() < ignoreInputUntilRef.current) { if (responseInFlightRef.current || Date.now() < ignoreInputUntilRef.current) {
break; break;
} }
enqueueEveTurn(transcript); enqueueEveTurn(transcript, event.itemId);
break; break;
} }
options.onEvent?.(event as EveVoiceEvent); options.onEvent?.(event as EveVoiceEvent);
@@ -481,7 +476,6 @@ export function useEveVoice(options: UseEveVoiceOptions = {}): UseEveVoiceResult
realtime.stopAudioCapture(); realtime.stopAudioCapture();
realtime.stopPlayback(); realtime.stopPlayback();
realtime.disconnect(); realtime.disconnect();
expectSpeechResponseRef.current = false;
ignoreInputUntilRef.current = 0; ignoreInputUntilRef.current = 0;
processedInputItemsRef.current.clear(); processedInputItemsRef.current.clear();
responseInFlightRef.current = false; responseInFlightRef.current = false;