From 013f70c7d70ff554e9f2e406d3e481858c6dfa0d Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 21 Jul 2026 21:13:14 +0000 Subject: [PATCH] fix(tts): prioritized playback interruption on escape - Moved active playback cancellation ahead of ongoing loop and stream interruption. - Added an overlapping TTS and agent-turn regression. Fixes #6118 --- packages/coding-agent/CHANGELOG.md | 4 ++++ .../src/modes/controllers/input-controller.ts | 15 ++++++++------- .../src/prompts/system/workflow-notice.md | 6 +----- .../test/input-controller-escape.test.ts | 18 ++++++++++++++++++ 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6f32dc885..f0217afac 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Esc aborting an ongoing agent turn instead of overlapping TTS playback, leaving speech uninterruptible ([#6118](https://github.com/can1357/oh-my-pi/issues/6118)). + ## [17.0.5] - 2026-07-18 ### Added diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 2ba96cfbc..97f46e07d 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -308,6 +308,14 @@ export class InputController { if (aborted) return; } + if (vocalizer.isSpeaking()) { + // Playback from the completed response can overlap the next agent + // turn. Silence it before interrupting any ongoing main-turn work. + vocalizer.clear(); + this.ctx.lastEscapeTime = 0; + return; + } + if (this.ctx.loopModeEnabled) { this.ctx.pauseLoop(); if (this.ctx.session.isStreaming) { @@ -360,13 +368,6 @@ export class InputController { } else if (this.ctx.editor.getText().trim()) { // Esc must not destroy an in-progress draft. this.ctx.lastEscapeTime = 0; - } else if (vocalizer.isSpeaking()) { - // TTS buffers seconds of PCM past the streaming abort, so an Esc - // arriving after the model stopped would otherwise fall through to - // the double-Esc gesture while Kokoro reads on. Silence first; - // tree/branch stays reachable via a second Esc. - vocalizer.clear(); - this.ctx.lastEscapeTime = 0; } else { // Double-interrupt with empty editor triggers /tree, /branch, or nothing based on setting const action = settings.get("doubleEscapeAction"); diff --git a/packages/coding-agent/src/prompts/system/workflow-notice.md b/packages/coding-agent/src/prompts/system/workflow-notice.md index d15ad9127..09a849bef 100644 --- a/packages/coding-agent/src/prompts/system/workflow-notice.md +++ b/packages/coding-agent/src/prompts/system/workflow-notice.md @@ -47,7 +47,7 @@ For independent per-item chains (review → verify, fetch → extract → score) schema: FINDINGS_SCHEMA, }); return await parallel(found.findings.map((f) => async () => ({ - ...f, + …f, verdict: await agent( `Refute if you can (default refuted when unsure): ${f.title}`, { label: `verify:${f.file}`, schema: VERDICT_SCHEMA }, @@ -57,8 +57,6 @@ For independent per-item chains (review → verify, fetch → extract → score) phase("Review"); const results = await parallel(DIMENSIONS.map((d) => async () => reviewAndVerify(d))); const confirmed = results.flat().filter((f) => f.verdict.is_real); - - Reach for `pipeline()` only when a stage genuinely needs ALL of the previous stage first — dedup/merge across the whole set, early-exit on zero, or "compare against the other findings" — because its inter-stage barrier makes every item wait for the slowest peer: **Python (`eval`, Python backend):** @@ -80,8 +78,6 @@ Reach for `pipeline()` only when a stage genuinely needs ALL of the previous sta const verdicts = await parallel(findings.map((f) => async () => await agent(verifyPrompt(f), { schema: VERDICT_SCHEMA }), )); - - Use ordinary code between calls to flatten/map/filter; don't add a barrier just for that. Nested `parallel()` pools each cap independently, so keep total fan-out sane. diff --git a/packages/coding-agent/test/input-controller-escape.test.ts b/packages/coding-agent/test/input-controller-escape.test.ts index c5fe89ce8..a5a06dea1 100644 --- a/packages/coding-agent/test/input-controller-escape.test.ts +++ b/packages/coding-agent/test/input-controller-escape.test.ts @@ -669,6 +669,24 @@ describe("InputController escape behavior", () => { expect(ctx.showUserMessageSelector).not.toHaveBeenCalled(); }); + it("silences TTS before aborting an overlapping agent turn (#6118)", () => { + const clear = vi.spyOn(vocalizer, "clear").mockImplementation(() => {}); + vi.spyOn(vocalizer, "isSpeaking").mockReturnValue(true); + const { ctx, editor, spies } = createContext(); + const pauseLoop = vi.fn(); + ctx.loopModeEnabled = true; + ctx.pauseLoop = pauseLoop; + mutableSessionState(ctx).isStreaming = true; + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + editor.onEscape?.(); + + expect(clear).toHaveBeenCalledTimes(1); + expect(pauseLoop).not.toHaveBeenCalled(); + expect(spies.abort).not.toHaveBeenCalled(); + }); + it("silences a still-audible vocalizer on Esc instead of opening the tree selector (#4521)", () => { const clear = vi.spyOn(vocalizer, "clear").mockImplementation(() => {}); const isSpeaking = vi.spyOn(vocalizer, "isSpeaking").mockReturnValue(true);