From bdceb660d3230e514e44e7095c1e4f989524a4c1 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 23 Jul 2026 18:39:11 +0000 Subject: [PATCH 1/2] fix(tts): preserved playback across internal turns Moved vocalizer clearing from each assistant/tool continuation boundary to the start of a new agent run. Added regression coverage for uninterrupted internal turns and new-run interruption. Fixes #6375 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/controllers/event-controller.ts | 8 ++------ .../controllers/event-controller-interrupt.test.ts | 13 +++++++++++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ad5f374bd..bd559c035 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed Assistant-mode TTS playback aborting when an agent continued after a tool call in the same run; internal turn boundaries now preserve queued speech, while a new agent run still interrupts prior playback. ([#6375](https://github.com/can1357/oh-my-pi/issues/6375)) - Fixed credential-shaped tokens (GitHub/GitLab/OpenAI/Anthropic key patterns) being redacted from outbound provider requests even with `secrets.enabled` off; the pattern redaction now follows the `secrets.enabled` ("Hide Secrets") setting like the secret obfuscator. - Fixed Ctrl-clicking a wrapped OAuth authorization URL opening only the clicked row's truncated fragment by preserving the complete hyperlink target on every rendered row. - Fixed used-only absolute usage amounts across output surfaces: CLI now renders `$123.45 used`; the TUI shows a neutral, width-bounded amount instead of a pending/dotted/account-count placeholder; and ACP preserves `123.45 usd used` while suppressing duplicate window suffixes such as `— extra`. ([#5575](https://github.com/can1357/oh-my-pi/issues/5575)) diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 6d7d8ca77..10e92d797 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -149,7 +149,7 @@ export class EventController { this.#handlers = { agent_start: e => this.#handleAgentStart(e), agent_end: e => this.#handleAgentEnd(e), - turn_start: async () => this.#handleTurnStart(), + turn_start: async () => {}, turn_end: async e => this.#handleTurnEnd(e), message_start: e => this.#handleMessageStart(e), message_update: e => this.#handleMessageUpdate(e), @@ -405,6 +405,7 @@ export class EventController { } async #handleAgentStart(_event: Extract): Promise { + vocalizer.clear(); this.#toolTimelineComponents.clear(); this.#postToolAssistantComponents.clear(); this.#lastIntent = undefined; @@ -644,11 +645,6 @@ export class EventController { } } - /** A new turn interrupts any speech still queued/playing from the previous one. */ - #handleTurnStart(): void { - vocalizer.clear(); - } - /** * Speak streamed assistant output as a side effect of the turn. The mode * decides which deltas feed the vocalizer (the vocalizer re-checks enabled): diff --git a/packages/coding-agent/test/modes/controllers/event-controller-interrupt.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-interrupt.test.ts index fd391daf7..7072b8c31 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-interrupt.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-interrupt.test.ts @@ -4,6 +4,7 @@ import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/eve import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { vocalizer } from "@oh-my-pi/pi-coding-agent/tts/vocalizer"; function createContext() { const setWorkingMessage = vi.fn(); @@ -61,6 +62,18 @@ describe("EventController aborted-turn working messages", () => { resetSettingsForTest(); }); + it("preserves playback across internal turns and clears it for a new agent run", async () => { + const clear = vi.spyOn(vocalizer, "clear").mockImplementation(() => {}); + const { ctx } = createContext(); + const controller = new EventController(ctx); + + await controller.handleEvent({ type: "turn_start" }); + expect(clear).not.toHaveBeenCalled(); + + await controller.handleEvent(AGENT_START); + expect(clear).toHaveBeenCalledTimes(1); + }); + it("suppresses late intent-driven working-message updates while aborting", async () => { const { ctx, pendingTools, setWorkingMessage, session } = createContext(); const controller = new EventController(ctx); From 741725612b69dcbaebcce389871065ecfaf47dab Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 23 Jul 2026 20:05:49 +0000 Subject: [PATCH 2/2] fix(tts): gated playback clearing to user messages Moved vocalizer interruption from agent_start to user message_start so agentLoopContinue preserves queued speech. Expanded regression coverage for continuation starts and user prompt boundaries. Fixes #6375 --- packages/coding-agent/CHANGELOG.md | 2 +- .../src/modes/controllers/event-controller.ts | 2 +- .../event-controller-interrupt.test.ts | 18 ++++++++++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bd559c035..e74c67963 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed Assistant-mode TTS playback aborting when an agent continued after a tool call in the same run; internal turn boundaries now preserve queued speech, while a new agent run still interrupts prior playback. ([#6375](https://github.com/can1357/oh-my-pi/issues/6375)) +- Fixed Assistant-mode TTS playback aborting when an agent continued after a tool call or automatic follow-up in the same run; internal continuation boundaries now preserve queued speech, while a new user message still interrupts prior playback. ([#6375](https://github.com/can1357/oh-my-pi/issues/6375)) - Fixed credential-shaped tokens (GitHub/GitLab/OpenAI/Anthropic key patterns) being redacted from outbound provider requests even with `secrets.enabled` off; the pattern redaction now follows the `secrets.enabled` ("Hide Secrets") setting like the secret obfuscator. - Fixed Ctrl-clicking a wrapped OAuth authorization URL opening only the clicked row's truncated fragment by preserving the complete hyperlink target on every rendered row. - Fixed used-only absolute usage amounts across output surfaces: CLI now renders `$123.45 used`; the TUI shows a neutral, width-bounded amount instead of a pending/dotted/account-count placeholder; and ACP preserves `123.45 usd used` while suppressing duplicate window suffixes such as `— extra`. ([#5575](https://github.com/can1357/oh-my-pi/issues/5575)) diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 10e92d797..1e2be54bd 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -405,7 +405,6 @@ export class EventController { } async #handleAgentStart(_event: Extract): Promise { - vocalizer.clear(); this.#toolTimelineComponents.clear(); this.#postToolAssistantComponents.clear(); this.#lastIntent = undefined; @@ -450,6 +449,7 @@ export class EventController { } this.ctx.ui.requestRender(); } else if (event.message.role === "user") { + vocalizer.clear(); const textContent = this.ctx.getUserMessageText(event.message); const imageBlocks = typeof event.message.content === "string" diff --git a/packages/coding-agent/test/modes/controllers/event-controller-interrupt.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-interrupt.test.ts index 7072b8c31..77b1218c1 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-interrupt.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-interrupt.test.ts @@ -22,6 +22,11 @@ function createContext() { transcriptMessageComponents: new WeakMap(), pendingTools, hideThinkingBlock: false, + getUserMessageText: () => "new prompt", + locallySubmittedUserSignatures: new Set(), + addMessageToChat: vi.fn(), + editor: { setText: vi.fn() }, + updatePendingMessagesDisplay: vi.fn(), setWorkingMessage, clearPinnedError: vi.fn(), ensureLoadingAnimation, @@ -62,15 +67,24 @@ describe("EventController aborted-turn working messages", () => { resetSettingsForTest(); }); - it("preserves playback across internal turns and clears it for a new agent run", async () => { + it("preserves playback across internal continuations and clears it for a user message", async () => { const clear = vi.spyOn(vocalizer, "clear").mockImplementation(() => {}); const { ctx } = createContext(); const controller = new EventController(ctx); + await controller.handleEvent(AGENT_START); await controller.handleEvent({ type: "turn_start" }); expect(clear).not.toHaveBeenCalled(); - await controller.handleEvent(AGENT_START); + await controller.handleEvent({ + type: "message_start", + message: { + role: "user", + content: [{ type: "text", text: "new prompt" }], + attribution: "user", + timestamp: Date.now(), + }, + }); expect(clear).toHaveBeenCalledTimes(1); });