diff --git a/src/ccbot/bot/_messages_shared.py b/src/ccbot/bot/_messages_shared.py index c31cd9fb..a7b5cc46 100644 --- a/src/ccbot/bot/_messages_shared.py +++ b/src/ccbot/bot/_messages_shared.py @@ -231,37 +231,32 @@ async def _wait_for_voice_transcript( async def _send_with_delivery_proof( wid: str, text: str, sess: Session | None ) -> tuple[bool, str]: - """Send one prompt and require an exact Codex transcript acknowledgement.""" - transcript_checkpoint = _voice_transcript_checkpoint(wid) - message = "" - for attempt in range(1, 3): - success, message = await session_manager.send_to_window(wid, text) - if not success: - continue - if message.startswith("Queued for "): - return True, message - if sess is None or sess.backend != "codex": - return True, message - if not await tmux_manager.ensure_codex_prompt_submitted(wid, text): - message = "Codex kept the text in its input field" - continue - # TUI slash commands do not become ordinary user_message rows. - if text.lstrip().startswith("/"): - return True, message - confirmed = await _wait_for_voice_transcript( - transcript_checkpoint, text, wid=wid - ) - if confirmed is True or confirmed is None: - return True, message - logger.warning( - "Codex delivery absent from transcript; retrying exact prompt " - "window=%s attempt=%d/2 text_len=%d", - wid, - attempt, - len(text), - ) - message = "Prompt did not appear in the Codex transcript" - return False, message or "Delivery was not acknowledged" + """Submit one prompt at most once and verify that the TUI accepted it. + + A busy Codex TUI accepts follow-up prompts into its own queue but does not + append their user rows to the rollout until it starts processing them. + Transcript absence is therefore not delivery failure and must never cause + the full prompt to be typed again. The only safe retry is an extra Enter, + handled by ``ensure_codex_prompt_submitted`` while the exact text is still + visibly present in the input field. + """ + success, message = await session_manager.send_to_window(wid, text) + if not success: + return False, message or "Delivery was not acknowledged" + if message.startswith("Queued for "): + return True, message + if sess is None or sess.backend != "codex": + return True, message + if not await tmux_manager.ensure_codex_prompt_submitted(wid, text): + return False, "Codex kept the text in its input field" + # TUI slash commands do not become ordinary user_message rows. + if text.lstrip().startswith("/"): + return True, message + # Reaching this point proves that text+Enter were sent and that the exact + # text no longer remains in the live input. Do not hold the inbound FIFO + # open waiting for a rollout row: a busy Codex writes that row only when it + # eventually consumes its queued prompt, which can take minutes. + return True, message def _enqueue_voice( diff --git a/src/ccbot/bot/_messages_voice.py b/src/ccbot/bot/_messages_voice.py index 89882563..eb98e25b 100644 --- a/src/ccbot/bot/_messages_voice.py +++ b/src/ccbot/bot/_messages_voice.py @@ -45,11 +45,8 @@ _download_voice_bytes = cast(Any, None) _enqueue_voice = cast(Any, None) _intercept_if_pending_ui = cast(Any, None) - _pane_has_interactive_ui = cast(Any, None) _release_voice = cast(Any, None) - _voice_transcript_checkpoint = cast(Any, None) _wait_for_voice = cast(Any, None) - _wait_for_voice_transcript = cast(Any, None) cancel_bash_capture = cast(Any, None) logger = logging.getLogger(__name__) @@ -290,37 +287,10 @@ async def _process_voice( # bash-capture, interactive-UI check, card repost) once the text is # known. No voice-specific reply; the transcribed text just becomes # this message's text, same as if the user had typed it. - transcript_checkpoint = _voice_transcript_checkpoint(wid) dispatched = await _dispatch_text_to_active(update, context, user.id, wid, text) if dispatched is False: return False - - # A prompt appearing after send is not proof that the voice was eaten: it - # can be an approval raised by the successfully delivered turn, especially - # for the second voice in a queue. Prefer the authoritative transcript and - # only use the pane heuristic when no matching user row appears. - transcript_confirmed = await _wait_for_voice_transcript( - transcript_checkpoint, text, wid=wid - ) - if transcript_confirmed is True: - logger.info( - "Voice delivery confirmed by transcript user=%d window=%s", - user.id, - wid, - ) - return True - if transcript_confirmed is None: - await asyncio.sleep(1.5) - if await _pane_has_interactive_ui(wid): - logger.warning( - "Voice delivery unconfirmed while interactive UI is visible " - "user=%d window=%s", - user.id, - wid, - ) - try: - await safe_reply(update.message, _voice_lost_notice) - except Exception: - pass - return False + # Dispatch already verified at-most-once TUI acceptance. A later approval + # or an absent transcript row can belong to an earlier busy turn and must + # not reclassify this queued voice as lost. return True diff --git a/tests/ccbot/test_startup_queue.py b/tests/ccbot/test_startup_queue.py index 6e8b6530..f2d2150d 100644 --- a/tests/ccbot/test_startup_queue.py +++ b/tests/ccbot/test_startup_queue.py @@ -260,12 +260,13 @@ async def test_first_turn_can_be_confirmed_after_binding_appears( @pytest.mark.asyncio -async def test_delivery_retries_until_exact_transcript_ack() -> None: +async def test_late_transcript_ack_never_retypes_an_accepted_prompt() -> None: from ccbot.bot.messages import _send_with_delivery_proof fake_manager = MagicMock() fake_manager.send_to_window = AsyncMock(return_value=(True, "Sent")) fake_session = SimpleNamespace(backend="codex") + transcript_wait = AsyncMock(return_value=False) with ( patch("ccbot.bot.messages.session_manager", fake_manager), patch( @@ -274,11 +275,34 @@ async def test_delivery_retries_until_exact_transcript_ack() -> None: ), patch( "ccbot.bot.messages._wait_for_voice_transcript", - new=AsyncMock(side_effect=[False, True]), + new=transcript_wait, ), patch("ccbot.bot.messages._voice_transcript_checkpoint", return_value=None), ): ok, _ = await _send_with_delivery_proof("@9", "do it", fake_session) assert ok - assert fake_manager.send_to_window.await_count == 2 + assert fake_manager.send_to_window.await_count == 1 + transcript_wait.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_prompt_left_in_input_fails_without_retyping_text() -> None: + from ccbot.bot.messages import _send_with_delivery_proof + + fake_manager = MagicMock() + fake_manager.send_to_window = AsyncMock(return_value=(True, "Sent")) + fake_session = SimpleNamespace(backend="codex") + with ( + patch("ccbot.bot.messages.session_manager", fake_manager), + patch( + "ccbot.bot.messages.tmux_manager.ensure_codex_prompt_submitted", + new=AsyncMock(return_value=False), + ), + patch("ccbot.bot.messages._voice_transcript_checkpoint", return_value=None), + ): + ok, message = await _send_with_delivery_proof("@9", "do it", fake_session) + + assert not ok + assert "input field" in message + assert fake_manager.send_to_window.await_count == 1 diff --git a/tests/ccbot/test_voice_session_pinning.py b/tests/ccbot/test_voice_session_pinning.py index 8dc7a118..f09a67b1 100644 --- a/tests/ccbot/test_voice_session_pinning.py +++ b/tests/ccbot/test_voice_session_pinning.py @@ -435,7 +435,7 @@ async def test_confirmed_transcript_beats_post_send_interactive_prompt(self): notice.assert_not_awaited() @pytest.mark.asyncio - async def test_unconfirmed_transcript_with_interactive_prompt_reports_loss(self): + async def test_successful_dispatch_is_not_reclassified_by_later_prompt(self): update = _make_voice_update() context = _make_context() mock_sm = MagicMock() @@ -444,6 +444,8 @@ async def test_unconfirmed_transcript_with_interactive_prompt_reports_loss(self) mock_tmux.find_window_by_id = AsyncMock(return_value=MagicMock(window_id="@5")) notice = AsyncMock() + transcript_wait = AsyncMock(return_value=False) + pane_check = AsyncMock(return_value=True) with ( patch("ccbot.bot.messages.is_user_allowed", return_value=True), patch("ccbot.bot.messages.resolve_voice_backend", return_value="whisper"), @@ -468,11 +470,11 @@ async def test_unconfirmed_transcript_with_interactive_prompt_reports_loss(self) ), patch( "ccbot.bot.messages._wait_for_voice_transcript", - new=AsyncMock(return_value=False), + new=transcript_wait, ), patch( "ccbot.bot.messages._pane_has_interactive_ui", - new=AsyncMock(return_value=True), + new=pane_check, ), patch("ccbot.bot.messages.safe_reply", new=notice), ): @@ -480,8 +482,10 @@ async def test_unconfirmed_transcript_with_interactive_prompt_reports_loss(self) delivered = await _process_voice(update, context, pinned_wid="@5") - assert delivered is False - notice.assert_awaited_once() + assert delivered is True + transcript_wait.assert_not_awaited() + pane_check.assert_not_awaited() + notice.assert_not_awaited() class TestVoiceSessionPinning: