fix(dictation): finalize recordings with abandoned partials (#4065)

Mid-stream silence clearing can leave a non-final partial whose segment will never commit. Treat those partials as UI-only once all requested commits settle so retries of the same recording can complete instead of timing out forever.
This commit is contained in:
Mohamed Boudra
2026-08-30 10:29:25 +02:00
committed by GitHub
parent 2618686677
commit 9113542c5a
2 changed files with 59 additions and 25 deletions
@@ -374,6 +374,54 @@ describe("DictationStreamManager (provider-agnostic provider)", () => {
);
});
it("does not wait for an abandoned partial after clearing mid-stream silence", async () => {
const session = new FakeRealtimeSession();
const emitted: Array<{ type: string; payload: unknown }> = [];
const manager = new DictationStreamManager({
logger: pino({ level: "silent" }),
emit: (message) => emitted.push(message),
sessionId: "s1",
stt: new FakeSttProvider(session),
autoCommitSeconds: 1,
});
await manager.handleStart("d-cleared-partial", "audio/pcm;rate=24000;bits=16");
await manager.handleChunk({
dictationId: "d-cleared-partial",
seq: 0,
audioBase64: buildPcmBase64(2000, 24000),
format: "audio/pcm;rate=24000;bits=16",
});
session.emitCommitted("seg-first");
session.emitTranscript("seg-first", "the beginning", true);
session.emitTranscript("seg-abandoned", "quiet partial", false);
await manager.handleChunk({
dictationId: "d-cleared-partial",
seq: 1,
audioBase64: buildPcmBase64(0, 24000),
format: "audio/pcm;rate=24000;bits=16",
});
expect(session.clearCalls).toBe(1);
await manager.handleChunk({
dictationId: "d-cleared-partial",
seq: 2,
audioBase64: buildPcmBase64(2000, 2400),
format: "audio/pcm;rate=24000;bits=16",
});
await manager.handleFinish("d-cleared-partial", 2);
session.emitCommitted("seg-final");
session.emitTranscript("seg-final", "the final words", true);
await tick();
const final = emitted.find((message) => message.type === "dictation_stream_final");
expect((final?.payload as { text?: string } | undefined)?.text).toBe(
"the beginning the final words",
);
expect(session.closed).toBe(true);
});
it("adapts finish timeout based on pending committed segments", async () => {
const session = new FakeRealtimeSession();
const emitted: Array<{ type: string; payload: unknown }> = [];
@@ -405,7 +453,7 @@ describe("DictationStreamManager (provider-agnostic provider)", () => {
).toBeGreaterThan(5000);
});
it("adapts finish timeout when only uncommitted non-final transcripts are pending", async () => {
it("does not extend the finish timeout for abandoned non-final transcripts", async () => {
const session = new FakeRealtimeSession();
const emitted: Array<{ type: string; payload: unknown }> = [];
const manager = new DictationStreamManager({
@@ -432,9 +480,7 @@ describe("DictationStreamManager (provider-agnostic provider)", () => {
const finishAccepted = emitted.find((msg) => msg.type === "dictation_stream_finish_accepted");
expect(finishAccepted).toBeDefined();
expect(
(finishAccepted?.payload as { timeoutMs?: number } | undefined)?.timeoutMs,
).toBeGreaterThan(5000);
expect((finishAccepted?.payload as { timeoutMs?: number } | undefined)?.timeoutMs).toBe(20_000);
});
it("drops dangling uncommitted non-final transcripts when finishing after silence tail clear", async () => {
@@ -579,17 +579,7 @@ export class DictationStreamManager {
const pendingCommittedSegments = state.committedSegmentIds.reduce((count, segmentId) => {
return state.finalTranscriptSegmentIds.has(segmentId) ? count : count + 1;
}, 0);
const committedSet = new Set(state.committedSegmentIds);
const pendingUncommittedTranscriptSegments = Array.from(
state.transcriptsBySegmentId.keys(),
).reduce((count, segmentId) => {
if (committedSet.has(segmentId)) {
return count;
}
return state.finalTranscriptSegmentIds.has(segmentId) ? count : count + 1;
}, 0);
const pendingSegments =
pendingCommittedSegments + pendingUncommittedTranscriptSegments + state.inFlightCommitCount;
const pendingSegments = pendingCommittedSegments + state.inFlightCommitCount;
const pendingAudioSeconds = Math.ceil(Math.max(0, state.bytesSinceCommit) / bytesPerSecond);
const missingSeqCount =
state.finalSeq === null ? 0 : Math.max(0, state.finalSeq - state.ackSeq);
@@ -670,16 +660,6 @@ export class DictationStreamManager {
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
state.awaitingFinalCommit = false;
const droppedSegments = this.dropUncommittedNonFinalTranscripts(state);
if (droppedSegments > 0) {
this.logger.debug(
{
dictationId,
droppedSegments,
},
"Dictation finish: dropped uncommitted non-final transcript segments after silence clear",
);
}
} else {
state.awaitingFinalCommit = true;
try {
@@ -732,6 +712,14 @@ export class DictationStreamManager {
return;
}
const droppedSegments = this.dropUncommittedNonFinalTranscripts(state);
if (droppedSegments > 0) {
this.logger.debug(
{ dictationId, droppedSegments },
"Dropped abandoned non-final dictation transcript segments before finalization",
);
}
const committedSet = new Set(state.committedSegmentIds);
const orderedSegmentIds: string[] = [...state.committedSegmentIds];
for (const segmentId of state.transcriptsBySegmentId.keys()) {