diff --git a/packages/server/src/server/dictation/dictation-stream-manager.test.ts b/packages/server/src/server/dictation/dictation-stream-manager.test.ts index dde4fad734..6d389a17e8 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.test.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.test.ts @@ -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 () => { diff --git a/packages/server/src/server/dictation/dictation-stream-manager.ts b/packages/server/src/server/dictation/dictation-stream-manager.ts index e62b975ef0..e911db3d6e 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.ts @@ -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()) {