From 9b59ce64267dbb1cb369adcd7a2dcd2aef67f25d Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:36:06 +0530 Subject: [PATCH] fix(tui_gateway): end turn audio before settlement, defer only the trim Gate finding (2c + quality, convergent): the settle-before-trim split moved the thinking-sound stop and the TTS end-of-text sentinel after session["running"] = False. Both are non-blocking (a lock + Event.set; an unbounded queue put), so deferring them gained nothing, and it opened a window where a prompt admitted on the same session during the trim had its thinking sound stopped by the old thread and the old stream's final chunk cut as a barge-in. They run in _release_turn_scopes again, before settlement; _post_turn_housekeeping keeps only the trim and the HERMES_HOME override reset. (The original commit subject says "trim/TTS"; only the trim is deferred now.) The settle-before-trim test now also asserts both audio teardown steps ran while the session was still running. --- tests/tui_gateway/test_prompt_accept_logging.py | 9 ++++++++- tui_gateway/prompt_turn.py | 16 ++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/tui_gateway/test_prompt_accept_logging.py b/tests/tui_gateway/test_prompt_accept_logging.py index 4a482dc81b..c1ffcda592 100644 --- a/tests/tui_gateway/test_prompt_accept_logging.py +++ b/tests/tui_gateway/test_prompt_accept_logging.py @@ -107,8 +107,10 @@ def test_accepted_and_finished_records_on_success(turn_env, caplog): def test_turn_settles_before_post_turn_trim(monkeypatch, caplog): - """A blocked post-turn trim must not hold the session running or its bookend (#131740).""" + """A blocked post-turn trim must not hold the session running or its bookend (#131740); + turn audio still ends BEFORE settlement, so a next turn admitted during the trim keeps its own.""" import hermes_cli.mem_trim as mem_trim + import tools.voice_mode as voice_mode entered, release = threading.Event(), threading.Event() @@ -122,6 +124,10 @@ def test_turn_settles_before_post_turn_trim(monkeypatch, caplog): monkeypatch.setattr(server, "_sync_agent_model_with_config", lambda sid, session: None) monkeypatch.setattr(server, "_tts_stream_begin", lambda: None) monkeypatch.setattr(server, "_get_usage", lambda agent: {}) + audio_end = [] # (event, session running at that moment) + tts = types.SimpleNamespace(put=lambda x: x is None and audio_end.append(("tts", session["running"]))) + monkeypatch.setattr(server, "_start_turn_voice", lambda: (tts, True)) + monkeypatch.setattr(voice_mode, "stop_thinking_sound", lambda: audio_end.append(("thinking", session["running"]))) agent = types.SimpleNamespace( session_id="agent-sid-1", run_conversation=lambda *a, **k: {"final_response": "done"}, clear_interrupt=lambda: None) @@ -133,6 +139,7 @@ def test_turn_settles_before_post_turn_trim(monkeypatch, caplog): assert entered.wait(timeout=5) assert session["running"] is False assert len(_records(caplog, "tui turn finished")) == 1 + assert audio_end == [("thinking", True), ("tts", True)] finally: release.set() session["_run_thread"].join(timeout=5) diff --git a/tui_gateway/prompt_turn.py b/tui_gateway/prompt_turn.py index 90d4290b9f..7ded53b405 100644 --- a/tui_gateway/prompt_turn.py +++ b/tui_gateway/prompt_turn.py @@ -1028,13 +1028,19 @@ def _recover_turn_exception(sid: str, session: dict, st: _TurnRun, e: BaseExcept def _release_turn_scopes(sid: str, session: dict, st: _TurnRun) -> None: - """Finally-path, before settlement: drop snapshots, undo a one-turn model, reset scopes (home stays bound).""" + """Finally-path, before settlement: drop snapshots, end turn audio, undo a one-turn model, reset scopes (not home).""" # Drop both pre-turn history snapshots before asking glibc to return pages (a test # inspects these two locals by name). history, run_kwargs = st.history, st.run_kwargs history.clear() if isinstance(run_kwargs, dict): run_kwargs.clear() + if st.thinking_started: + with contextlib.suppress(Exception): + from tools.voice_mode import stop_thinking_sound + stop_thinking_sound() + if st.tts_queue is not None: + st.tts_queue.put(None) # end-of-text sentinel — flush + finish speaking if st.one_turn_restore: try: _restore_agent_model_runtime(st.agent, st.one_turn_restore) @@ -1057,7 +1063,7 @@ def _release_turn_scopes(sid: str, session: dict, st: _TurnRun) -> None: def _post_turn_housekeeping(sid: str, session: dict, st: _TurnRun) -> None: - """Best-effort tail AFTER the turn settled: a slow trim/TTS flush must not hold the bookend (#131740).""" + """Best-effort tail AFTER the turn settled: a slow trim must not hold the bookend (#131740).""" try: # while the profile HERMES_HOME override is still active (session's own config) from hermes_cli.mem_trim import trim_memory # Every OTHER session must be idle (#58576). @@ -1065,12 +1071,6 @@ def _post_turn_housekeeping(sid: str, session: dict, st: _TurnRun) -> None: trim_memory(reason="tui turn completion") except Exception: logger.debug("post-turn memory trim failed", exc_info=True) - if st.thinking_started: - with contextlib.suppress(Exception): - from tools.voice_mode import stop_thinking_sound - stop_thinking_sound() - if st.tts_queue is not None: - st.tts_queue.put(None) # end-of-text sentinel — flush + finish speaking if st.scopes.home is not None: reset_hermes_home_override(st.scopes.home)