Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 26 additions & 31 deletions src/ccbot/bot/_messages_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
36 changes: 3 additions & 33 deletions src/ccbot/bot/_messages_voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
30 changes: 27 additions & 3 deletions tests/ccbot/test_startup_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
14 changes: 9 additions & 5 deletions tests/ccbot/test_voice_session_pinning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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"),
Expand All @@ -468,20 +470,22 @@ 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),
):
from ccbot.bot.messages import _process_voice

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:
Expand Down
Loading