Skip to content

Commit 578b502

Browse files
author
happydog-bot
committed
fix(voice-call): treat final user transcript as authoritative, drop partial buffer
When a realtime provider streams partial user-transcript chunks (isFinal=false) and then emits the same sentence as the fully aggregated final (isFinal=true) — the Gemini native-audio behavior observed live — the handler was calling recordPartialUserTranscript() on the final too, re-appending the final text onto the partial buffer that already held all the chunks. The persisted speech event ended up with duplicated content, e.g. "I'm just te sting some thing. I'm just testing something.". Clear the partial buffer and use the final text directly, so the speech event matches what the provider actually sent. Adds a regression test.
1 parent 243a6df commit 578b502

2 files changed

Lines changed: 82 additions & 1 deletion

File tree

extensions/voice-call/src/webhook/realtime-handler.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,83 @@ describe("RealtimeCallHandler path routing", () => {
10631063
}
10641064
});
10651065

1066+
it("does not concatenate streaming user deltas onto the final user transcript", async () => {
1067+
// Regression: providers (notably Gemini native-audio) stream partial
1068+
// user-transcript chunks via isFinal=false and then deliver the same
1069+
// sentence as the fully aggregated final via isFinal=true. The handler
1070+
// must treat the final text as authoritative and not re-append it to the
1071+
// partial buffer, or the persisted speech text duplicates the streaming
1072+
// chunks (e.g. "I'm just te sting some thing. I'm just testing something.").
1073+
let callbacks:
1074+
| {
1075+
onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
1076+
}
1077+
| undefined;
1078+
const processEvent = vi.fn();
1079+
const createBridge = vi.fn(
1080+
(request: Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0]) => {
1081+
callbacks = request;
1082+
return makeBridge();
1083+
},
1084+
);
1085+
const handler = makeHandler(undefined, {
1086+
manager: {
1087+
processEvent,
1088+
getCallByProviderCallId: vi.fn(
1089+
(): CallRecord => ({
1090+
callId: "call-dedup",
1091+
providerCallId: "CA-dedup",
1092+
provider: "twilio",
1093+
direction: "inbound",
1094+
state: "ringing",
1095+
from: "+15550001234",
1096+
to: "+15550009999",
1097+
startedAt: Date.now(),
1098+
transcript: [],
1099+
processedEventIds: [],
1100+
metadata: {},
1101+
}),
1102+
),
1103+
},
1104+
realtimeProvider: makeRealtimeProvider(createBridge),
1105+
});
1106+
const server = await startRealtimeServer(handler);
1107+
1108+
try {
1109+
const ws = await connectWs(server.url);
1110+
try {
1111+
ws.send(
1112+
JSON.stringify({
1113+
event: "start",
1114+
start: { streamSid: "MZ-dedup", callSid: "CA-dedup" },
1115+
}),
1116+
);
1117+
await waitForRealtimeTest(() => {
1118+
expect(createBridge).toHaveBeenCalled();
1119+
});
1120+
1121+
callbacks?.onTranscript?.("user", " I'm just te", false);
1122+
callbacks?.onTranscript?.("user", " sting some thing.", false);
1123+
callbacks?.onTranscript?.("user", "I'm just testing something.", true);
1124+
1125+
const speechTranscripts = processEvent.mock.calls
1126+
.map(([event]) => event as NormalizedEvent)
1127+
.filter(
1128+
(event): event is Extract<NormalizedEvent, { type: "call.speech" }> =>
1129+
event.type === "call.speech",
1130+
)
1131+
.map((event) => event.transcript);
1132+
expect(speechTranscripts).toEqual(["I'm just testing something."]);
1133+
} finally {
1134+
if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
1135+
ws.close();
1136+
}
1137+
}
1138+
} finally {
1139+
await server.close();
1140+
}
1141+
});
1142+
10661143
it("waits for partial transcript fragments to settle before consulting", async () => {
10671144
let callbacks:
10681145
| {

extensions/voice-call/src/webhook/realtime-handler.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -723,8 +723,12 @@ export class RealtimeCallHandler {
723723
return;
724724
}
725725
if (role === "user") {
726-
const transcript = this.recordPartialUserTranscript(callId, text);
726+
// Final user transcript from the realtime provider is already the
727+
// fully aggregated turn; do not re-append it to the partial buffer
728+
// (which has been collecting streaming deltas), or the persisted
729+
// text duplicates the streaming chunks with the final.
727730
this.clearPartialUserTranscript(callId);
731+
const transcript = text.trim();
728732
this.setRecentFinalUserTranscript(callId, transcript);
729733
console.log(
730734
`[voice-call] realtime input transcript callId=${callId} providerCallId=${callSid} final=true chars=${text.trim().length} aggregateChars=${transcript.length}`,

0 commit comments

Comments
 (0)