项目文件夹

文件
Ali Khokhar 3fba1c6fc3 Give messaging clear exact subtree semantics (#1072)
## Problem

Messaging `/clear` did not follow the selected platform message
boundary. Reply clears preserved the selected user prompt, while
standalone clears preserved user-authored messages and could reset FCC
state outside the invoking chat.

## Changes

| Before | After |
| --- | --- |
| Reply `/clear` removed a logical conversation branch but retained the
selected message. | Reply `/clear` deletes the selected message and its
literal reply subtree, including the clear command. |
| Standalone `/clear` retained user prompts and voice notes while
resetting global messaging state. | Standalone `/clear` deletes every
tracked message and resets FCC state only in the invoking platform and
chat. |
| Trees recorded only logical execution parentage. | Trees separately
persist logical execution ancestry and exact prompt/status reply
ownership. |
| Clear coordination used one global admission boundary. | Per-chat
clear generations coordinate admission, voice cancellation, persistence,
and best-effort platform deletion. |
| Persistence tracked only FCC-authored clearable output. | Persistence
tracks managed inbound and outbound messages and migrates legacy
entries. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR gives messaging `/clear` exact per-chat and reply-subtree
behavior. The main changes are:

- Per-chat clear generations for admission and startup-notice cleanup.
- Managed inbound and outbound message tracking for deletion.
- Exact prompt/status reply ownership in message trees.
- Scoped voice cancellation and clear persistence updates.
- Updated docs, smoke coverage, and messaging tests.
</details>

<h3>Confidence Score: 4/5</h3>

The clear flow is mostly well-contained, with one upgrade-path issue in
legacy tree restoration.

Newly created prompt/status subtrees use the new exact reference fields
consistently, and legacy snapshots can map old status replies to prompt
references. However, reply `/clear` on an upgraded status can miss
descendants and leave stale state/messages.

src/free_claude_code/messaging/trees/snapshot.py

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- T-Rex attempted to prepare and run a focused legacy snapshot
reproduction harness for legacy status replies detach, but tool access
was blocked before execution.
- A messaging clear smoke test harness was executed and reported a
passing result: 20 items collected and 20 passed in 1.69 seconds, with
traces for test\_reply\_clear\_uses\_literal and related paths shown in
the log.

<a
href="https://app.greptile.com/trex/runs/14131337/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| src/free_claude_code/messaging/workflow.py | Adds per-chat clear
generations, managed inbound recording, scoped clears, and
startup-notice invalidation. |
| src/free_claude_code/messaging/trees/graph.py | Adds exact
prompt/status reference resolution and literal reply-subtree traversal.
|
| src/free_claude_code/messaging/trees/runtime.py | Adds exact
message-subtree removal and status-only clearing behavior. |
| src/free_claude_code/messaging/trees/snapshot.py | Adds
parent_reference_id persistence and legacy fallback; the fallback can
miss legacy status-reply descendants. |
| src/free_claude_code/messaging/session/managed_message_log.py |
Replaces the clearable output log with managed inbound and outbound
message tracking. |
| src/free_claude_code/messaging/commands.py | Routes reply and
standalone `/clear` through the new exact deletion ID flows. |

</details>

<details open><summary><h3>Flowchart</h3></summary>

<a href="#gh-light-mode-only">

```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Incoming message] --> B{Standalone /clear?}
B -- yes --> C[Clear invoking chat]
C --> D[Cancel scoped voice work]
C --> E[Collect managed and tree message IDs]
C --> F[Advance chat clear generation]
F --> G[Detach scoped trees]
G --> H[Clear scoped session store]
H --> I[Best-effort platform deletes]
B -- no --> J[Record managed inbound message]
J --> K[Admit with stop and clear token]
K --> L{Reply /clear?}
L -- yes --> M[Resolve exact prompt or status reference]
M --> N[Remove literal reference subtree]
N --> I
L -- no --> O[Queue or run tree node]
```

</a>
<a href="#gh-dark-mode-only">

```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[Incoming message] --> B{Standalone /clear?}
B -- yes --> C[Clear invoking chat]
C --> D[Cancel scoped voice work]
C --> E[Collect managed and tree message IDs]
C --> F[Advance chat clear generation]
F --> G[Detach scoped trees]
G --> H[Clear scoped session store]
H --> I[Best-effort platform deletes]
B -- no --> J[Record managed inbound message]
J --> K[Admit with stop and clear token]
K --> L{Reply /clear?}
L -- yes --> M[Resolve exact prompt or status reference]
M --> N[Remove literal reference subtree]
N --> I
L -- no --> O[Queue or run tree node]
```

</a>
</details>

<a
href="https://app.greptile.com/api/ide/codex?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22alishahryar1%2Ffree-claude-code%22%20on%20the%20existing%20branch%20%22ali%2Fclear-message-subtree%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22ali%2Fclear-message-subtree%22.%0A%0AFix%20the%20following%201%20code%20review%20issue.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%201%0Asrc%2Ffree_claude_code%2Fmessaging%2Ftrees%2Fsnapshot.py%3A192-193%0A**Legacy%20Status%20Replies%20Detach**%0A%0AWhen%20an%20upgraded%20legacy%20snapshot%20contains%20a%20child%20that%20originally%20replied%20to%20its%20parent%20status%2C%20this%20fallback%20rewrites%20the%20missing%20exact%20reference%20to%20the%20parent%20prompt.%20A%20later%20reply%20%60%2Fclear%60%20on%20that%20status%20traverses%20from%20the%20status%20ID%2C%20finds%20no%20migrated%20child%20edge%2C%20and%20leaves%20the%20old%20status-reply%20descendants%20and%20their%20managed%20messages%20behind.%0A%0A&repo=alishahryar1%2Ffree-claude-code&pr=1072&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodexDark.svg?v=6"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"><img
alt="Fix All in Codex"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"></picture></a>

<sub>Reviews (1): Last reviewed commit: ["Give messaging clear exact
subtree
seman..."](https://github.com/alishahryar1/free-claude-code/commit/0a3baecf9470da4bb866c864c1d1f01abd517085)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=43593126)</sub>

> Greptile also left **1 inline comment** on this PR.

<!-- /greptile_comment -->
2026-07-11 22:26:16 -07:00

928 行
29 KiB
Python

import asyncio
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from free_claude_code.messaging.models import MessageScope
from free_claude_code.messaging.platforms.voice_flow import (
VOICE_DISABLED_MESSAGE,
VOICE_TRANSCRIPTION_ERROR_MESSAGE,
VoiceNoteFlow,
VoiceNoteRequest,
audio_suffix_from_metadata,
is_audio_metadata,
)
from free_claude_code.messaging.trees.runtime import MessageTree
from free_claude_code.messaging.voice import Transcriber
from free_claude_code.messaging.workflow import MessagingWorkflow
VOICE_SCOPE = MessageScope(platform="telegram", chat_id="chat")
class FatalVoiceError(BaseException):
"""Fatal sentinel used to verify ownership finalization."""
class MockTranscriber:
def __init__(self, result: str = "hello from voice") -> None:
self.run = AsyncMock(return_value=result)
self.close_run = AsyncMock()
self.paths: list[Path] = []
async def transcribe(self, file_path: Path) -> str:
self.paths.append(file_path)
return await self.run(file_path)
async def close(self) -> None:
await self.close_run()
def _flow(*, enabled: bool = True) -> tuple[VoiceNoteFlow, MockTranscriber]:
transcriber = MockTranscriber()
configured: Transcriber | None = transcriber if enabled else None
return (
VoiceNoteFlow(
transcriber=configured,
log_raw_messaging_content=False,
log_api_error_tracebacks=False,
),
transcriber,
)
def _request(
*,
download_to=None,
reply_text=None,
message_id: str = "voice",
) -> VoiceNoteRequest:
async def default_download_to(path: Path) -> None:
path.write_bytes(b"voice")
return VoiceNoteRequest(
platform="telegram",
chat_id="chat",
user_id="user",
message_id=message_id,
raw_event={"raw": True},
content_type="audio/ogg",
temp_suffix=".ogg",
status_text="transcribing",
status_parse_mode="MarkdownV2",
message_thread_id="thread",
reply_to_message_id="reply",
download_to=download_to or default_download_to,
reply_text=reply_text or AsyncMock(),
)
@pytest.mark.asyncio
async def test_voice_flow_success_builds_incoming_message() -> None:
flow, transcriber = _flow()
handler = AsyncMock()
queue_send = AsyncMock(return_value="status")
queue_delete = AsyncMock()
downloaded_paths: list[Path] = []
async def download_to(path: Path) -> None:
downloaded_paths.append(path)
path.write_bytes(b"voice")
handled = await flow.handle(
_request(download_to=download_to),
message_handler=handler,
queue_send_message=queue_send,
queue_delete_messages=queue_delete,
)
assert handled is True
queue_send.assert_awaited_once_with(
"chat",
"transcribing",
reply_to="voice",
parse_mode="MarkdownV2",
fire_and_forget=False,
message_thread_id="thread",
)
queue_delete.assert_not_awaited()
handler.assert_awaited_once()
incoming = handler.call_args.args[0]
assert incoming.text == "hello from voice"
assert incoming.chat_id == "chat"
assert incoming.message_id == "voice"
assert incoming.reply_to_message_id == "reply"
assert incoming.message_thread_id == "thread"
assert incoming.status_message_id == "status"
transcriber.run.assert_awaited_once()
assert transcriber.paths == downloaded_paths
assert downloaded_paths and not downloaded_paths[0].exists()
@pytest.mark.asyncio
async def test_voice_flow_disabled_replies_without_transcribing() -> None:
flow, transcriber = _flow(enabled=False)
reply_text = AsyncMock()
handled = await flow.handle(
_request(reply_text=reply_text),
message_handler=AsyncMock(),
queue_send_message=AsyncMock(),
queue_delete_messages=AsyncMock(),
)
assert handled is True
reply_text.assert_awaited_once_with(VOICE_DISABLED_MESSAGE)
transcriber.run.assert_not_awaited()
@pytest.mark.asyncio
async def test_voice_flow_missing_status_id_stops_before_transcription() -> None:
flow, transcriber = _flow()
reply_text = AsyncMock()
handler = AsyncMock()
queue_delete = AsyncMock()
handled = await flow.handle(
_request(reply_text=reply_text),
message_handler=handler,
queue_send_message=AsyncMock(return_value=None),
queue_delete_messages=queue_delete,
)
assert handled is True
transcriber.run.assert_not_awaited()
handler.assert_not_awaited()
queue_delete.assert_not_awaited()
reply_text.assert_awaited_once_with(VOICE_TRANSCRIPTION_ERROR_MESSAGE)
assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None
@pytest.mark.asyncio
async def test_voice_flow_cancelled_transcription_preserves_owned_status() -> None:
flow, transcriber = _flow()
async def canceling_transcribe(_path: Path) -> str:
await flow.cancel_pending_voice(VOICE_SCOPE, "voice")
return "ignored"
transcriber.run.side_effect = canceling_transcribe
handler = AsyncMock()
queue_send = AsyncMock(return_value="status")
queue_delete = AsyncMock()
handled = await flow.handle(
_request(),
message_handler=handler,
queue_send_message=queue_send,
queue_delete_messages=queue_delete,
)
assert handled is True
handler.assert_not_awaited()
queue_delete.assert_not_awaited()
@pytest.mark.asyncio
async def test_voice_flow_cancel_during_status_delivery_prevents_transcription() -> (
None
):
flow, transcriber = _flow()
status_send_started = asyncio.Event()
release_status_send = asyncio.Event()
handler = AsyncMock()
queue_delete = AsyncMock()
async def send_status(*_args, **_kwargs) -> str:
status_send_started.set()
await release_status_send.wait()
return "status"
handle_task = asyncio.create_task(
flow.handle(
_request(),
message_handler=handler,
queue_send_message=send_status,
queue_delete_messages=queue_delete,
)
)
try:
await asyncio.wait_for(status_send_started.wait(), timeout=1)
cancelled = await flow.cancel_pending_voice(VOICE_SCOPE, "voice")
assert cancelled is not None
assert cancelled.voice_message_id == "voice"
assert cancelled.status_message_id is None
release_status_send.set()
assert await asyncio.wait_for(handle_task, timeout=1) is True
finally:
release_status_send.set()
if not handle_task.done():
handle_task.cancel()
with pytest.raises(asyncio.CancelledError):
await handle_task
transcriber.run.assert_not_awaited()
handler.assert_not_awaited()
queue_delete.assert_awaited_once_with("chat", ["status"])
@pytest.mark.asyncio
async def test_voice_flow_cancel_during_status_delivery_suppresses_late_failure() -> (
None
):
flow, transcriber = _flow()
status_send_started = asyncio.Event()
release_status_send = asyncio.Event()
reply_text = AsyncMock()
handler = AsyncMock()
async def fail_status_send(*_args, **_kwargs) -> str:
status_send_started.set()
await release_status_send.wait()
raise RuntimeError("late status failure")
handle_task = asyncio.create_task(
flow.handle(
_request(reply_text=reply_text),
message_handler=handler,
queue_send_message=fail_status_send,
queue_delete_messages=AsyncMock(),
)
)
try:
await asyncio.wait_for(status_send_started.wait(), timeout=1)
assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is not None
release_status_send.set()
assert await asyncio.wait_for(handle_task, timeout=1) is True
finally:
release_status_send.set()
if not handle_task.done():
handle_task.cancel()
with pytest.raises(asyncio.CancelledError):
await handle_task
transcriber.run.assert_not_awaited()
handler.assert_not_awaited()
reply_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_voice_flow_cancel_during_transcription_suppresses_late_failure() -> None:
flow, transcriber = _flow()
transcription_started = asyncio.Event()
release_transcription = asyncio.Event()
reply_text = AsyncMock()
handler = AsyncMock()
queue_delete = AsyncMock()
async def fail_transcription(_path: Path) -> str:
transcription_started.set()
await release_transcription.wait()
raise RuntimeError("late transcription failure")
transcriber.run.side_effect = fail_transcription
handle_task = asyncio.create_task(
flow.handle(
_request(reply_text=reply_text),
message_handler=handler,
queue_send_message=AsyncMock(return_value="status"),
queue_delete_messages=queue_delete,
)
)
try:
await asyncio.wait_for(transcription_started.wait(), timeout=1)
cancelled = await flow.cancel_pending_voice(VOICE_SCOPE, "voice")
assert cancelled is not None
assert cancelled.status_message_id == "status"
release_transcription.set()
assert await asyncio.wait_for(handle_task, timeout=1) is True
finally:
release_transcription.set()
if not handle_task.done():
handle_task.cancel()
with pytest.raises(asyncio.CancelledError):
await handle_task
handler.assert_not_awaited()
reply_text.assert_not_awaited()
queue_delete.assert_not_awaited()
@pytest.mark.asyncio
async def test_reply_stop_status_survives_late_transcription_success(
mock_platform,
mock_cli_manager,
mock_session_store,
incoming_message_factory,
) -> None:
flow, transcriber = _flow()
workflow = MessagingWorkflow(
mock_platform,
mock_cli_manager,
mock_session_store,
platform_name="telegram",
voice_cancellation=flow,
)
transcription_started = asyncio.Event()
release_transcription = asyncio.Event()
async def delayed_transcription(_path: Path) -> str:
transcription_started.set()
await release_transcription.wait()
return "late voice prompt"
transcriber.run.side_effect = delayed_transcription
mock_platform.queue_send_message.return_value = "voice_status"
voice_task = asyncio.create_task(
flow.handle(
_request(),
message_handler=workflow.handle_message,
queue_send_message=mock_platform.queue_send_message,
queue_delete_messages=mock_platform.queue_delete_messages,
)
)
await asyncio.wait_for(transcription_started.wait(), timeout=1)
try:
await workflow.handle_message(
incoming_message_factory(
text="/stop",
chat_id="chat",
message_id="stop_command",
reply_to_message_id="voice",
)
)
finally:
release_transcription.set()
assert await asyncio.wait_for(voice_task, timeout=1) is True
await asyncio.sleep(0)
stopped = workflow.format_status("⏹", "Stopped.")
assert any(
call.args[:3] == ("chat", "voice_status", stopped)
for call in mock_platform.queue_edit_message.await_args_list
)
mock_platform.queue_delete_messages.assert_not_awaited()
assert await workflow.tree_queue.resolve_node_id(VOICE_SCOPE, "voice") is None
@pytest.mark.asyncio
async def test_voice_flow_cancel_during_handoff_stops_and_drains_handler() -> None:
flow, _transcriber = _flow()
handler_started = asyncio.Event()
handler_cancelled = asyncio.Event()
release_handler = asyncio.Event()
queue_delete = AsyncMock()
async def handler(_incoming) -> None:
handler_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
handler_cancelled.set()
await release_handler.wait()
raise
handle_task = asyncio.create_task(
flow.handle(
_request(),
message_handler=handler,
queue_send_message=AsyncMock(return_value="status"),
queue_delete_messages=queue_delete,
)
)
try:
await asyncio.wait_for(handler_started.wait(), timeout=1)
cancel_task = asyncio.create_task(
flow.cancel_pending_voice(VOICE_SCOPE, "voice")
)
await asyncio.wait_for(handler_cancelled.wait(), timeout=1)
assert not cancel_task.done()
release_handler.set()
cancelled = await asyncio.wait_for(cancel_task, timeout=1)
assert cancelled is not None
assert cancelled.status_message_id == "status"
assert await asyncio.wait_for(handle_task, timeout=1) is True
finally:
release_handler.set()
if not handle_task.done():
handle_task.cancel()
with pytest.raises(asyncio.CancelledError):
await handle_task
queue_delete.assert_not_awaited()
@pytest.mark.asyncio
async def test_voice_flow_cancel_during_handoff_suppresses_late_handler_error() -> None:
flow, _transcriber = _flow()
handler_started = asyncio.Event()
reply_text = AsyncMock()
async def handler(_incoming) -> None:
handler_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
raise RuntimeError("late handler failure") from None
handle_task = asyncio.create_task(
flow.handle(
_request(reply_text=reply_text),
message_handler=handler,
queue_send_message=AsyncMock(return_value="status"),
queue_delete_messages=AsyncMock(),
)
)
await asyncio.wait_for(handler_started.wait(), timeout=1)
assert await flow.cancel_pending_voice(VOICE_SCOPE, "status") is not None
assert await asyncio.wait_for(handle_task, timeout=1) is True
reply_text.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("command", ["/stop", "/clear"])
async def test_reply_command_cancels_voice_at_tree_admission_commit(
command: str,
monkeypatch: pytest.MonkeyPatch,
mock_platform,
mock_cli_manager,
mock_session_store,
incoming_message_factory,
) -> None:
flow, _transcriber = _flow()
workflow = MessagingWorkflow(
mock_platform,
mock_cli_manager,
mock_session_store,
platform_name="telegram",
voice_cancellation=flow,
)
admission_mutated = asyncio.Event()
release_admission = asyncio.Event()
original_enqueue_or_claim = MessageTree.enqueue_or_claim
async def mutate_then_block(tree: MessageTree, node_id: str):
decision = await original_enqueue_or_claim(tree, node_id)
if node_id == "voice":
admission_mutated.set()
await release_admission.wait()
return decision
monkeypatch.setattr(MessageTree, "enqueue_or_claim", mutate_then_block)
mock_platform.queue_send_message.return_value = "voice_status"
voice_task = asyncio.create_task(
flow.handle(
_request(),
message_handler=workflow.handle_message,
queue_send_message=mock_platform.queue_send_message,
queue_delete_messages=mock_platform.queue_delete_messages,
)
)
await asyncio.wait_for(admission_mutated.wait(), timeout=1)
command_task = asyncio.create_task(
workflow.handle_message(
incoming_message_factory(
text=command,
chat_id="chat",
message_id="command",
reply_to_message_id="voice",
)
)
)
try:
await asyncio.sleep(0)
assert not command_task.done()
finally:
release_admission.set()
await asyncio.wait_for(command_task, timeout=1)
voice_result = await asyncio.wait_for(voice_task, timeout=1)
assert voice_result is True
await asyncio.sleep(0)
mock_session_store.save_tree_snapshot.assert_called()
if command == "/clear":
assert workflow.get_tree_count() == 0
assert await workflow.tree_queue.resolve_node_id(VOICE_SCOPE, "voice") is None
else:
assert workflow.get_tree_count() == 1
assert (
await workflow.tree_queue.resolve_node_id(VOICE_SCOPE, "voice") == "voice"
)
@pytest.mark.asyncio
@pytest.mark.parametrize("command", ["/stop", "/clear"])
async def test_global_command_rejects_transcription_that_finishes_late(
command: str,
mock_platform,
mock_cli_manager,
mock_session_store,
incoming_message_factory,
) -> None:
flow, transcriber = _flow()
workflow = MessagingWorkflow(
mock_platform,
mock_cli_manager,
mock_session_store,
platform_name="telegram",
voice_cancellation=flow,
)
transcription_started = asyncio.Event()
release_transcription = asyncio.Event()
async def delayed_transcription(_path: Path) -> str:
transcription_started.set()
await release_transcription.wait()
return "late voice prompt"
transcriber.run.side_effect = delayed_transcription
mock_platform.queue_send_message.return_value = "voice_status"
voice_task = asyncio.create_task(
flow.handle(
_request(),
message_handler=workflow.handle_message,
queue_send_message=mock_platform.queue_send_message,
queue_delete_messages=mock_platform.queue_delete_messages,
)
)
await asyncio.wait_for(transcription_started.wait(), timeout=1)
await asyncio.wait_for(
workflow.handle_message(
incoming_message_factory(
text=command,
message_id="command",
chat_id=VOICE_SCOPE.chat_id,
)
),
timeout=1,
)
release_transcription.set()
assert await asyncio.wait_for(voice_task, timeout=1) is True
assert workflow.get_tree_count() == 0
assert await workflow.tree_queue.resolve_node_id(VOICE_SCOPE, "voice") is None
@pytest.mark.asyncio
@pytest.mark.parametrize("command", ["/stop", "/clear"])
async def test_transcribed_global_command_does_not_cancel_its_own_handoff(
command: str,
mock_platform,
mock_cli_manager,
mock_session_store,
) -> None:
flow, transcriber = _flow()
transcriber.run.return_value = command
workflow = MessagingWorkflow(
mock_platform,
mock_cli_manager,
mock_session_store,
platform_name="telegram",
voice_cancellation=flow,
)
mock_platform.queue_send_message.return_value = "voice_status"
assert (
await asyncio.wait_for(
flow.handle(
_request(),
message_handler=workflow.handle_message,
queue_send_message=mock_platform.queue_send_message,
queue_delete_messages=mock_platform.queue_delete_messages,
),
timeout=1,
)
is True
)
assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None
@pytest.mark.asyncio
async def test_voice_flow_caller_cancellation_drains_handoff_child() -> None:
flow, _transcriber = _flow()
handler_started = asyncio.Event()
handler_cancelled = asyncio.Event()
release_handler = asyncio.Event()
queue_delete = AsyncMock()
async def handler(_incoming) -> None:
handler_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
handler_cancelled.set()
await release_handler.wait()
raise
handle_task = asyncio.create_task(
flow.handle(
_request(),
message_handler=handler,
queue_send_message=AsyncMock(return_value="status"),
queue_delete_messages=queue_delete,
)
)
await asyncio.wait_for(handler_started.wait(), timeout=1)
handle_task.cancel()
await asyncio.wait_for(handler_cancelled.wait(), timeout=1)
assert not handle_task.done()
release_handler.set()
with pytest.raises(asyncio.CancelledError):
await handle_task
assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None
queue_delete.assert_not_awaited()
@pytest.mark.asyncio
async def test_voice_flow_task_cancellation_waits_then_cleans_pending_state() -> None:
flow, transcriber = _flow()
started = asyncio.Event()
cancellation_received = asyncio.Event()
release = asyncio.Event()
stopped = asyncio.Event()
async def cancellation_safe_transcribe(_path: Path) -> str:
started.set()
try:
await asyncio.Event().wait()
return "unreachable"
except asyncio.CancelledError:
cancellation_received.set()
await release.wait()
stopped.set()
raise
transcriber.run.side_effect = cancellation_safe_transcribe
handler = AsyncMock()
queue_delete = AsyncMock()
handle_task = asyncio.create_task(
flow.handle(
_request(),
message_handler=handler,
queue_send_message=AsyncMock(return_value="status"),
queue_delete_messages=queue_delete,
)
)
await started.wait()
handle_task.cancel()
await cancellation_received.wait()
assert not handle_task.done()
queue_delete.assert_not_awaited()
release.set()
with pytest.raises(asyncio.CancelledError):
await handle_task
assert stopped.is_set()
handler.assert_not_awaited()
queue_delete.assert_awaited_once_with("chat", ["status"])
assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None
@pytest.mark.asyncio
async def test_voice_flow_repeated_cancellation_cannot_interrupt_cleanup(
monkeypatch: pytest.MonkeyPatch,
) -> None:
flow, transcriber = _flow()
transcription_started = asyncio.Event()
cleanup_started = asyncio.Event()
release_cleanup = asyncio.Event()
async def blocked_transcription(_path: Path) -> str:
transcription_started.set()
await asyncio.Event().wait()
return "unreachable"
original_discard = flow._pending_voice.discard
async def delayed_discard(claim) -> bool:
cleanup_started.set()
await release_cleanup.wait()
return await original_discard(claim)
transcriber.run.side_effect = blocked_transcription
monkeypatch.setattr(flow._pending_voice, "discard", delayed_discard)
queue_delete = AsyncMock()
handle_task = asyncio.create_task(
flow.handle(
_request(),
message_handler=AsyncMock(),
queue_send_message=AsyncMock(return_value="status"),
queue_delete_messages=queue_delete,
)
)
await asyncio.wait_for(transcription_started.wait(), timeout=1)
handle_task.cancel("first cancellation")
await asyncio.wait_for(cleanup_started.wait(), timeout=1)
handle_task.cancel("second cancellation")
await asyncio.sleep(0)
assert not handle_task.done()
release_cleanup.set()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(handle_task, timeout=1)
queue_delete.assert_awaited_once_with("chat", ["status"])
assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None
assert await flow.cancel_pending_voice(VOICE_SCOPE, "status") is None
@pytest.mark.asyncio
async def test_voice_flow_fatal_status_failure_releases_claim() -> None:
flow, transcriber = _flow()
queue_delete = AsyncMock()
async def fatal_status(*_args, **_kwargs) -> str:
raise FatalVoiceError
with pytest.raises(FatalVoiceError):
await flow.handle(
_request(),
message_handler=AsyncMock(),
queue_send_message=fatal_status,
queue_delete_messages=queue_delete,
)
transcriber.run.assert_not_awaited()
queue_delete.assert_not_awaited()
assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None
@pytest.mark.asyncio
async def test_voice_flow_fatal_download_failure_releases_status_ownership() -> None:
flow, transcriber = _flow()
queue_delete = AsyncMock()
async def fatal_download(_path: Path) -> None:
raise FatalVoiceError
with pytest.raises(FatalVoiceError):
await flow.handle(
_request(download_to=fatal_download),
message_handler=AsyncMock(),
queue_send_message=AsyncMock(return_value="status"),
queue_delete_messages=queue_delete,
)
transcriber.run.assert_not_awaited()
queue_delete.assert_awaited_once_with("chat", ["status"])
assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None
assert await flow.cancel_pending_voice(VOICE_SCOPE, "status") is None
@pytest.mark.asyncio
async def test_voice_flow_fatal_transcription_releases_status_ownership() -> None:
flow, transcriber = _flow()
transcriber.run.side_effect = FatalVoiceError()
handler = AsyncMock()
queue_delete = AsyncMock()
with pytest.raises(FatalVoiceError):
await flow.handle(
_request(),
message_handler=handler,
queue_send_message=AsyncMock(return_value="status"),
queue_delete_messages=queue_delete,
)
handler.assert_not_awaited()
queue_delete.assert_awaited_once_with("chat", ["status"])
assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None
assert await flow.cancel_pending_voice(VOICE_SCOPE, "status") is None
@pytest.mark.asyncio
async def test_voice_flow_download_failure_cleans_pending_state() -> None:
flow, transcriber = _flow()
reply_text = AsyncMock()
queue_delete = AsyncMock()
async def failing_download(_path: Path) -> None:
raise RuntimeError("download failed")
handled = await flow.handle(
_request(download_to=failing_download, reply_text=reply_text),
message_handler=AsyncMock(),
queue_send_message=AsyncMock(return_value="status"),
queue_delete_messages=queue_delete,
)
assert handled is True
transcriber.run.assert_not_awaited()
queue_delete.assert_awaited_once_with("chat", ["status"])
reply_text.assert_awaited_once_with(VOICE_TRANSCRIPTION_ERROR_MESSAGE)
assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None
@pytest.mark.asyncio
async def test_voice_flow_transcription_failure_cleans_pending_state() -> None:
flow, transcriber = _flow()
transcriber.run.side_effect = RuntimeError("transcription failed")
reply_text = AsyncMock()
queue_delete = AsyncMock()
handled = await flow.handle(
_request(reply_text=reply_text),
message_handler=AsyncMock(),
queue_send_message=AsyncMock(return_value="status"),
queue_delete_messages=queue_delete,
)
assert handled is True
queue_delete.assert_awaited_once_with("chat", ["status"])
reply_text.assert_awaited_once_with(VOICE_TRANSCRIPTION_ERROR_MESSAGE)
assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None
@pytest.mark.asyncio
async def test_voice_flow_handler_failure_cleans_pending_without_deleting_status() -> (
None
):
flow, _transcriber = _flow()
reply_text = AsyncMock()
queue_delete = AsyncMock()
async def failing_handler(_incoming) -> None:
raise RuntimeError("handler failed")
handled = await flow.handle(
_request(reply_text=reply_text),
message_handler=failing_handler,
queue_send_message=AsyncMock(return_value="status"),
queue_delete_messages=queue_delete,
)
assert handled is True
queue_delete.assert_not_awaited()
reply_text.assert_awaited_once_with(VOICE_TRANSCRIPTION_ERROR_MESSAGE)
assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None
@pytest.mark.asyncio
async def test_voice_flow_rejects_oversized_audio_before_transcription(
monkeypatch,
) -> None:
monkeypatch.setattr(
"free_claude_code.messaging.platforms.voice_flow.MAX_AUDIO_SIZE_BYTES",
3,
)
flow, transcriber = _flow()
reply_text = AsyncMock()
queue_delete = AsyncMock()
async def download(path: Path) -> None:
path.write_bytes(b"four")
handled = await flow.handle(
_request(download_to=download, reply_text=reply_text),
message_handler=AsyncMock(),
queue_send_message=AsyncMock(return_value="status"),
queue_delete_messages=queue_delete,
)
assert handled is True
transcriber.run.assert_not_awaited()
queue_delete.assert_awaited_once_with("chat", ["status"])
assert reply_text.await_args is not None
assert "too large" in reply_text.await_args.args[0]
def test_audio_metadata_helpers() -> None:
assert is_audio_metadata("voice.ogg", "application/octet-stream") is True
assert is_audio_metadata("file.txt", "audio/ogg") is True
assert is_audio_metadata("file.txt", "text/plain") is False
assert (
audio_suffix_from_metadata(filename="voice.ogg", content_type="audio/mp4")
== ".mp4"
)
assert (
audio_suffix_from_metadata(filename="clip.m4a", content_type="audio/mp4")
== ".m4a"
)
assert (
audio_suffix_from_metadata(filename="clip.m4a", content_type="audio/mpeg")
== ".mp3"
)
assert audio_suffix_from_metadata(content_type="audio/mpeg") == ".mp3"
assert audio_suffix_from_metadata(filename="clip.m4a") == ".m4a"
assert audio_suffix_from_metadata(content_type="audio/mp4") == ".mp4"
assert audio_suffix_from_metadata(content_type="audio/wav") == ".wav"