Files
wehub-resource-sync 5296d0e97c
CI / Ban suppressions and legacy annotations (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff-check (push) Has been cancelled
CI / ruff-format (push) Has been cancelled
CI / ty (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:44 +08:00

2500 lines
74 KiB
Python

import asyncio
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from free_claude_code.messaging.command_context import ReplyClearResult, StopOutcome
from free_claude_code.messaging.models import MessageScope
from free_claude_code.messaging.platforms.ports import MessagingStartupNotice
from free_claude_code.messaging.session import SessionStore
from free_claude_code.messaging.trees import (
CancellationReason,
CancellationResult,
CancellationUiOwner,
FailureResult,
MessageReferenceKind,
MessageState,
MessageSubtreeRemovalResult,
NodeClaim,
NodeUiTarget,
QueueEntry,
ReplyTarget,
TreeIdentity,
TreeQueueManager,
TreeSnapshot,
)
from free_claude_code.messaging.trees.transitions import CancellationEffect
from free_claude_code.messaging.voice import VoiceCancellationResult
from free_claude_code.messaging.workflow import MessagingWorkflow
_SCOPE = MessageScope(platform="telegram", chat_id="chat_1")
_OTHER_SCOPE = MessageScope(platform="telegram", chat_id="other_chat")
def _stop_outcome(
cancelled_count: int,
*,
scopes: frozenset[MessageScope] = frozenset({_SCOPE}),
fallback_required: bool = False,
) -> StopOutcome:
return StopOutcome(cancelled_count, scopes, fallback_required)
def _startup_notice(chat_id: str = _SCOPE.chat_id) -> MessagingStartupNotice:
return MessagingStartupNotice(chat_id=chat_id, transport_label="Bot API")
async def _event_stream(events):
for event in events:
await asyncio.sleep(0)
yield event
def _claim(
node_id: str = "node_1",
*,
prompt: str = "hello",
parent_session_id: str | None = None,
) -> NodeClaim:
return NodeClaim(
identity=TreeIdentity(scope=_SCOPE, root_id="root_1"),
claim_id="claim_1",
node=NodeUiTarget(
scope=_SCOPE,
node_id=node_id,
status_message_id="status_1",
),
prompt=prompt,
parent_session_id=parent_session_id,
)
def _snapshot(root_id: str = "root_1") -> TreeSnapshot:
return TreeSnapshot(scope=_SCOPE, root_id=root_id, nodes={})
def _session(events) -> MagicMock:
session = MagicMock()
session.start_task.return_value = _event_stream(events)
return session
async def _wait_for_idle(workflow: MessagingWorkflow) -> None:
for _ in range(200):
if workflow.tree_queue.task_count() == 0:
await asyncio.sleep(0)
return
await asyncio.sleep(0.01)
raise AssertionError("messaging workflow did not become idle")
@pytest.fixture
def handler(mock_platform, mock_cli_manager, mock_session_store):
default_session = _session([{"type": "exit", "code": 0}])
mock_cli_manager.get_or_create_session.return_value = (
default_session,
"session_1",
False,
)
return MessagingWorkflow(
mock_platform,
mock_cli_manager,
mock_session_store,
platform_name="telegram",
voice_cancellation=mock_platform,
)
@pytest.mark.asyncio
async def test_handle_message_turn_trace_always_includes_full_message_text(
mock_platform,
mock_cli_manager,
mock_session_store,
incoming_message_factory,
):
text = "user-message-content-visible-in-trace"
workflow = MessagingWorkflow(
mock_platform,
mock_cli_manager,
mock_session_store,
)
incoming = incoming_message_factory(text=text)
with (
patch.object(workflow.turn_intake, "handle_message", new_callable=AsyncMock),
patch("free_claude_code.messaging.workflow.trace_event") as trace_mock,
):
await workflow.handle_message(incoming)
assert trace_mock.call_args.kwargs["event"] == "turn.received"
assert trace_mock.call_args.kwargs["message_text"] == text
@pytest.mark.asyncio
async def test_user_prompt_and_status_are_recorded_for_global_clear(
handler,
mock_session_store,
incoming_message_factory,
) -> None:
incoming = incoming_message_factory(text="keep me", message_id="user-prompt")
await handler.handle_message(incoming)
await _wait_for_idle(handler)
mock_session_store.record_message_id.assert_has_calls(
[
call(
incoming.platform,
incoming.chat_id,
"user-prompt",
"in",
"prompt",
),
call(
incoming.platform,
incoming.chat_id,
"msg_123",
"out",
"status",
),
]
)
@pytest.mark.parametrize(
("target", "expected"),
[
(None, "Launching"),
(
ReplyTarget(
node_id="parent",
reference_id="parent",
reference_kind=MessageReferenceKind.PROMPT,
queue_position=None,
),
"Continuing",
),
(
ReplyTarget(
node_id="parent",
reference_id="status-parent",
reference_kind=MessageReferenceKind.STATUS,
queue_position=3,
),
"position 3",
),
],
)
def test_initial_status_uses_immutable_reply_advice(handler, target, expected):
assert expected in handler.turn_intake._get_initial_status(target)
@pytest.mark.asyncio
async def test_global_stop_success_uses_existing_status_without_confirmation(
handler, mock_platform, incoming_message_factory
):
incoming = incoming_message_factory(text="/stop")
handler.stop_all_tasks = AsyncMock(return_value=_stop_outcome(5))
await handler.handle_message(incoming)
handler.stop_all_tasks.assert_awaited_once()
mock_platform.queue_send_message.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"status_feedback_scopes",
(
frozenset({_OTHER_SCOPE}),
frozenset({_SCOPE, _OTHER_SCOPE}),
),
ids=("remote-only", "mixed-local-and-remote"),
)
async def test_global_stop_reports_cross_chat_work(
handler,
mock_platform,
incoming_message_factory,
status_feedback_scopes: frozenset[MessageScope],
) -> None:
incoming = incoming_message_factory(text="/stop")
handler.stop_all_tasks = AsyncMock(
return_value=_stop_outcome(2, scopes=status_feedback_scopes)
)
await handler.handle_message(incoming)
assert (
"Cancelled 2 pending or active requests"
in mock_platform.queue_send_message.call_args.args[1]
)
@pytest.mark.asyncio
async def test_global_stop_noop_reports_nothing_to_stop(
handler, mock_platform, incoming_message_factory
) -> None:
incoming = incoming_message_factory(text="/stop")
handler.stop_all_tasks = AsyncMock(
return_value=_stop_outcome(0, scopes=frozenset())
)
await handler.handle_message(incoming)
mock_platform.queue_send_message.assert_awaited_once_with(
incoming.chat_id,
"⏹ *Stopped\\.* Nothing to stop\\.",
fire_and_forget=False,
message_thread_id=None,
)
@pytest.mark.asyncio
async def test_global_stop_without_status_target_sends_fallback_confirmation(
handler, mock_platform, incoming_message_factory
) -> None:
incoming = incoming_message_factory(text="/stop")
handler.stop_all_tasks = AsyncMock(
return_value=_stop_outcome(
1,
scopes=frozenset(),
fallback_required=True,
)
)
await handler.handle_message(incoming)
assert (
"Cancelled 1 pending or active request"
in mock_platform.queue_send_message.call_args.args[1]
)
@pytest.mark.asyncio
async def test_failed_global_stop_never_reports_success(
handler, mock_platform, incoming_message_factory
) -> None:
incoming = incoming_message_factory(text="/stop")
handler.stop_all_tasks = AsyncMock(
side_effect=RuntimeError("Failed to stop 1 managed Claude session.")
)
with pytest.raises(RuntimeError, match="Failed to stop"):
await handler.handle_message(incoming)
mock_platform.queue_send_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_reply_stop_resolves_and_stops_only_target(
handler, mock_platform, mock_cli_manager, incoming_message_factory
):
handler.stop_reply = AsyncMock(return_value=_stop_outcome(1))
handler.stop_all_tasks = AsyncMock(return_value=_stop_outcome(999))
incoming = incoming_message_factory(
text="/stop",
message_id="stop_msg",
reply_to_message_id="status_root",
)
await handler.handle_message(incoming)
handler.stop_reply.assert_awaited_once_with(incoming.scope, "status_root")
handler.stop_all_tasks.assert_not_awaited()
mock_cli_manager.stop_all.assert_not_awaited()
mock_platform.queue_send_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_reply_stop_unknown_does_not_stop_all(
handler, mock_platform, mock_cli_manager, incoming_message_factory
):
handler.stop_reply = AsyncMock(return_value=_stop_outcome(0, scopes=frozenset()))
handler.stop_all_tasks = AsyncMock(return_value=_stop_outcome(5))
incoming = incoming_message_factory(
text="/stop",
message_id="stop_msg",
reply_to_message_id="unknown_msg",
)
await handler.handle_message(incoming)
handler.stop_reply.assert_awaited_once_with(incoming.scope, "unknown_msg")
handler.stop_all_tasks.assert_not_awaited()
mock_cli_manager.stop_all.assert_not_awaited()
assert (
"Nothing to stop for that message"
in mock_platform.queue_send_message.call_args.args[1]
)
@pytest.mark.asyncio
async def test_reply_stop_cancels_voice_when_no_tree_was_admitted(
handler,
mock_platform,
incoming_message_factory,
) -> None:
mock_platform.cancel_pending_voice.return_value = VoiceCancellationResult(
scope=_SCOPE,
voice_message_id="voice",
status_message_id="voice_status",
delete_message_ids=frozenset({"voice", "voice_status"}),
)
incoming = incoming_message_factory(
text="/stop",
message_id="stop_msg",
reply_to_message_id="voice",
)
await handler.handle_message(incoming)
await asyncio.sleep(0)
mock_platform.queue_send_message.assert_not_awaited()
assert mock_platform.queue_edit_message.call_args.args[1] == "voice_status"
assert "Stopped" in mock_platform.queue_edit_message.call_args.args[2]
@pytest.mark.asyncio
async def test_reply_stop_without_voice_status_sends_fallback_confirmation(
handler,
mock_platform,
incoming_message_factory,
) -> None:
mock_platform.cancel_pending_voice.return_value = VoiceCancellationResult(
scope=_SCOPE,
voice_message_id="voice",
status_message_id=None,
delete_message_ids=frozenset({"voice"}),
)
incoming = incoming_message_factory(
text="/stop",
message_id="stop_msg",
reply_to_message_id="voice",
)
await handler.handle_message(incoming)
assert "Cancelled 1 request" in mock_platform.queue_send_message.call_args.args[1]
mock_platform.queue_edit_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_reply_stop_resolves_tree_after_joining_voice_handoff(
handler,
mock_platform,
incoming_message_factory,
) -> None:
events: list[str] = []
async def cancel_voice(*_args) -> VoiceCancellationResult:
events.append("voice")
return VoiceCancellationResult(
scope=_SCOPE,
voice_message_id="voice",
status_message_id="voice_status",
delete_message_ids=frozenset({"voice", "voice_status"}),
)
async def resolve_tree(*_args) -> str:
events.append("tree")
return "voice"
mock_platform.cancel_pending_voice.side_effect = cancel_voice
cancellation = CancellationResult(
effects=(
CancellationEffect(
node=NodeUiTarget(
scope=_SCOPE,
node_id="voice",
status_message_id="voice_status",
),
ui_owner=CancellationUiOwner.WORKFLOW,
),
)
)
incoming = incoming_message_factory(
text="/stop",
message_id="stop_msg",
reply_to_message_id="voice",
)
with (
patch.object(
handler.tree_queue,
"resolve_node_id",
side_effect=resolve_tree,
),
patch.object(
handler.tree_queue,
"cancel_node",
new_callable=AsyncMock,
return_value=cancellation,
) as cancel_node,
):
await handler.handle_message(incoming)
cancel_node.assert_awaited_once_with(
incoming.scope,
"voice",
reason=CancellationReason.STOP,
)
assert events == ["voice", "tree"]
mock_platform.queue_send_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_reply_stop_port_failure_prevents_tree_transition(
handler,
mock_platform,
) -> None:
mock_platform.cancel_pending_voice.side_effect = RuntimeError("voice port failed")
with (
patch.object(
handler.tree_queue,
"resolve_node_id",
new_callable=AsyncMock,
) as resolve,
pytest.raises(RuntimeError, match="voice port failed"),
):
await handler.stop_reply(_SCOPE, "voice")
resolve.assert_not_awaited()
mock_platform.queue_edit_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_reply_stop_renders_voice_before_resolve_failure(
handler,
mock_platform,
) -> None:
mock_platform.cancel_pending_voice.return_value = VoiceCancellationResult(
scope=_SCOPE,
voice_message_id="voice",
status_message_id="voice_status",
delete_message_ids=frozenset({"voice", "voice_status"}),
)
with (
patch.object(
handler.tree_queue,
"resolve_node_id",
AsyncMock(side_effect=RuntimeError("resolve failed")),
),
pytest.raises(RuntimeError, match="resolve failed"),
):
await handler.stop_reply(_SCOPE, "voice")
await asyncio.sleep(0)
mock_platform.queue_edit_message.assert_awaited_once()
assert mock_platform.queue_edit_message.call_args.args[1] == "voice_status"
@pytest.mark.asyncio
async def test_cancelled_reply_stop_finishes_after_voice_join_and_lock_wait(
handler,
mock_platform,
) -> None:
voice_returned = asyncio.Event()
async def cancel_voice(*_args) -> VoiceCancellationResult:
voice_returned.set()
return VoiceCancellationResult(
scope=_SCOPE,
voice_message_id="voice",
status_message_id="voice_status",
delete_message_ids=frozenset({"voice", "voice_status"}),
)
mock_platform.cancel_pending_voice.side_effect = cancel_voice
resolve = AsyncMock(return_value=None)
await handler._state_lock.acquire()
try:
with patch.object(handler.tree_queue, "resolve_node_id", resolve):
stop_task = asyncio.create_task(handler.stop_reply(_SCOPE, "voice"))
await voice_returned.wait()
await asyncio.sleep(0)
stop_task.cancel()
stop_task.cancel()
await asyncio.sleep(0)
assert not stop_task.done()
handler._state_lock.release()
with pytest.raises(asyncio.CancelledError):
await stop_task
finally:
if handler._state_lock.locked():
handler._state_lock.release()
await asyncio.sleep(0)
assert stop_task.cancelling() == 2
resolve.assert_awaited_once_with(_SCOPE, "voice")
mock_platform.queue_edit_message.assert_awaited_once()
assert mock_platform.queue_edit_message.call_args.args[1] == "voice_status"
@pytest.mark.asyncio
async def test_cancelled_reply_stop_preserves_later_operation_failure(
handler,
mock_platform,
) -> None:
voice_returned = asyncio.Event()
failure = RuntimeError("resolve failed after cancellation")
async def cancel_voice(*_args) -> None:
voice_returned.set()
mock_platform.cancel_pending_voice.side_effect = cancel_voice
resolve = AsyncMock(side_effect=failure)
await handler._state_lock.acquire()
try:
with patch.object(handler.tree_queue, "resolve_node_id", resolve):
stop_task = asyncio.create_task(handler.stop_reply(_SCOPE, "voice"))
await voice_returned.wait()
await asyncio.sleep(0)
stop_task.cancel()
await asyncio.sleep(0)
assert not stop_task.done()
handler._state_lock.release()
with pytest.raises(RuntimeError) as raised:
await stop_task
finally:
if handler._state_lock.locked():
handler._state_lock.release()
assert raised.value is failure
assert stop_task.cancelling() == 1
resolve.assert_awaited_once_with(_SCOPE, "voice")
@pytest.mark.asyncio
async def test_stats_command_reports_cli_and_tree_counts(
handler, mock_platform, mock_cli_manager, incoming_message_factory
):
mock_cli_manager.get_stats.return_value = {"active_sessions": 2}
await handler.handle_message(incoming_message_factory(text="/stats"))
text = mock_platform.queue_send_message.call_args.args[1]
assert "Active CLI: 2" in text
assert "Message Trees: 0" in text
assert mock_platform.queue_send_message.call_args.kwargs["fire_and_forget"] is False
@pytest.mark.asyncio
async def test_status_echo_is_filtered(
handler, mock_platform, incoming_message_factory
):
await handler.handle_message(incoming_message_factory(text="⏳ Thinking..."))
mock_platform.queue_send_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_new_turn_uses_public_admission_and_persists_exact_snapshot(
handler,
mock_platform,
mock_session_store,
incoming_message_factory,
):
incoming = incoming_message_factory(text="hello", message_id="node_1")
mock_platform.queue_send_message.return_value = "status_123"
await handler.handle_message(incoming)
await _wait_for_idle(handler)
assert "Launching" in mock_platform.queue_send_message.call_args.args[1]
assert mock_session_store.save_tree_snapshot.call_count >= 2
view = await handler.tree_queue.get_node(incoming.scope, "node_1")
assert view is not None
assert view.state is MessageState.COMPLETED
@pytest.mark.asyncio
async def test_duplicate_delivery_removes_its_provisional_status(
handler,
mock_platform,
mock_session_store,
incoming_message_factory,
):
incoming = incoming_message_factory(text="hello", message_id="duplicate")
mock_platform.queue_send_message.side_effect = ["status-first", "status-rejected"]
await handler.handle_message(incoming)
await _wait_for_idle(handler)
await handler.handle_message(incoming)
mock_platform.queue_delete_messages.assert_awaited_once_with(
incoming.chat_id,
["status-rejected"],
fire_and_forget=False,
)
mock_session_store.forget_tracked_message_ids.assert_called_once_with(
incoming.platform,
incoming.chat_id,
{"status-rejected"},
)
@pytest.mark.asyncio
async def test_pre_sent_status_is_edited_in_place(
handler, mock_platform, incoming_message_factory
):
incoming = incoming_message_factory(
text="hello",
message_id="node_1",
status_message_id="existing_status",
)
await handler.handle_message(incoming)
await _wait_for_idle(handler)
first_edit = mock_platform.queue_edit_message.call_args_list[0]
assert first_edit.args[1] == "existing_status"
assert "Launching" in first_edit.args[2]
assert first_edit.kwargs["fire_and_forget"] is False
mock_platform.queue_send_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_busy_reply_is_rendered_with_atomic_queue_position(
handler, mock_platform, mock_cli_manager, incoming_message_factory
):
started = asyncio.Event()
async def blocking_start(*args, **kwargs):
started.set()
await asyncio.sleep(60)
if False:
yield {}
session = MagicMock()
session.start_task = blocking_start
mock_cli_manager.get_or_create_session.return_value = (
session,
"session_1",
False,
)
mock_platform.queue_send_message.side_effect = ["status_root", "status_child"]
root = incoming_message_factory(text="root", message_id="root")
await handler.handle_message(root)
await started.wait()
child = incoming_message_factory(
text="child",
message_id="child",
reply_to_message_id="status_root",
)
await handler.handle_message(child)
assert "position 1" in mock_platform.queue_send_message.call_args.args[1]
queued_edit = mock_platform.queue_edit_message.call_args_list[-1]
assert queued_edit.args[1] == "status_child"
assert "position 1" in queued_edit.args[2]
await handler.stop_all_tasks()
@pytest.mark.asyncio
async def test_queue_position_callback_consumes_immutable_entries(
handler, mock_platform
):
queue = (
QueueEntry(
node=NodeUiTarget(
scope=_SCOPE,
node_id="child_1",
status_message_id="status_1",
),
position=1,
),
QueueEntry(
node=NodeUiTarget(
scope=_SCOPE,
node_id="child_2",
status_message_id="status_2",
),
position=2,
),
)
await handler.turn_intake.update_queue_positions(queue)
await asyncio.sleep(0)
calls = mock_platform.queue_edit_message.call_args_list
assert [call.args[1] for call in calls] == ["status_1", "status_2"]
assert "position 1" in calls[0].args[2]
assert "position 2" in calls[1].args[2]
@pytest.mark.asyncio
async def test_claim_started_callback_renders_processing(handler, mock_platform):
claim = _claim()
await handler.turn_intake.mark_node_processing(claim)
await asyncio.sleep(0)
args, kwargs = mock_platform.queue_edit_message.call_args
assert args[0:2] == ("chat_1", "status_1")
assert "Processing" in args[2]
assert kwargs["parse_mode"] == "MarkdownV2"
@pytest.mark.asyncio
async def test_stop_all_applies_immutable_ui_ownership_and_snapshots(
handler, mock_cli_manager, mock_platform, mock_session_store
):
workflow_owned = CancellationEffect(
node=NodeUiTarget(
scope=_SCOPE,
node_id="queued",
status_message_id="status_queued",
),
ui_owner=CancellationUiOwner.WORKFLOW,
)
runner_owned = CancellationEffect(
node=NodeUiTarget(
scope=_SCOPE,
node_id="active",
status_message_id="status_active",
),
ui_owner=CancellationUiOwner.RUNNER,
)
snapshot = _snapshot()
result = CancellationResult(
effects=(workflow_owned, runner_owned),
snapshots=(snapshot,),
)
with patch.object(
handler.tree_queue,
"cancel_all",
AsyncMock(return_value=result),
) as cancel_all:
outcome = await handler.stop_all_tasks()
await asyncio.sleep(0)
assert outcome == _stop_outcome(2)
cancel_all.assert_awaited_once_with(reason=CancellationReason.STOP)
mock_cli_manager.stop_all.assert_awaited_once()
assert mock_platform.fire_and_forget.call_count == 1
assert mock_platform.queue_edit_message.call_args.args[1] == "status_queued"
mock_session_store.save_tree_snapshot.assert_called_once_with(snapshot)
@pytest.mark.asyncio
async def test_stop_all_joins_voices_before_tree_transaction_and_deduplicates(
handler,
mock_cli_manager,
mock_platform,
) -> None:
events: list[str] = []
voice = VoiceCancellationResult(
scope=_SCOPE,
voice_message_id="voice",
status_message_id=None,
delete_message_ids=frozenset({"voice"}),
)
tree_result = CancellationResult(
effects=(
CancellationEffect(
node=NodeUiTarget(
scope=_SCOPE,
node_id="voice",
status_message_id="voice_status",
),
ui_owner=CancellationUiOwner.WORKFLOW,
),
)
)
async def cancel_voices() -> tuple[VoiceCancellationResult, ...]:
assert not handler._state_lock.locked()
events.append("voices")
return (voice,)
async def cancel_trees(*, reason: CancellationReason) -> CancellationResult:
assert reason is CancellationReason.STOP
assert handler._state_lock.locked()
events.append("trees")
return tree_result
mock_platform.cancel_all_pending_voices.side_effect = cancel_voices
with patch.object(handler.tree_queue, "cancel_all", side_effect=cancel_trees):
outcome = await handler.stop_all_tasks()
await asyncio.sleep(0)
assert outcome == _stop_outcome(1)
assert events == ["voices", "trees"]
mock_cli_manager.stop_all.assert_awaited_once()
mock_platform.queue_edit_message.assert_awaited_once()
assert mock_platform.queue_edit_message.call_args.args[1] == "voice_status"
@pytest.mark.asyncio
async def test_stop_all_voice_join_failure_prevents_false_transition(
handler,
mock_cli_manager,
mock_platform,
) -> None:
mock_platform.cancel_all_pending_voices.side_effect = RuntimeError(
"voice handoff cleanup failed"
)
with (
patch.object(
handler.tree_queue, "cancel_all", new_callable=AsyncMock
) as cancel,
pytest.raises(RuntimeError, match="voice handoff cleanup failed"),
):
await handler.stop_all_tasks()
cancel.assert_not_awaited()
mock_cli_manager.stop_all.assert_not_awaited()
@pytest.mark.asyncio
async def test_stop_all_persists_committed_transition_before_cli_shutdown(
handler,
mock_cli_manager,
mock_session_store,
):
shutdown_started = asyncio.Event()
release_shutdown = asyncio.Event()
snapshot = _snapshot()
result = CancellationResult(snapshots=(snapshot,))
async def block_shutdown() -> None:
shutdown_started.set()
await release_shutdown.wait()
mock_cli_manager.stop_all.side_effect = block_shutdown
with patch.object(
handler.tree_queue,
"cancel_all",
AsyncMock(return_value=result),
):
stop_task = asyncio.create_task(handler.stop_all_tasks())
await shutdown_started.wait()
mock_session_store.save_tree_snapshot.assert_called_once_with(snapshot)
stop_task.cancel()
stop_task.cancel()
await asyncio.sleep(0)
assert not stop_task.done()
release_shutdown.set()
with pytest.raises(asyncio.CancelledError):
await stop_task
assert stop_task.cancelling() == 2
@pytest.mark.asyncio
async def test_terminal_close_waits_past_interactive_drain_timeout(
monkeypatch,
mock_platform,
mock_cli_manager,
mock_session_store,
incoming_message_factory,
) -> None:
monkeypatch.setattr(
"free_claude_code.messaging.trees.manager.CANCEL_TASK_DRAIN_TIMEOUT_S",
0.01,
)
runner_started = asyncio.Event()
cancellation_seen = asyncio.Event()
release_cleanup = asyncio.Event()
cli_stop_seen = asyncio.Event()
async def cancellation_delayed_runner(_claim: NodeClaim) -> None:
runner_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancellation_seen.set()
await release_cleanup.wait()
raise
async def stop_cli() -> None:
cli_stop_seen.set()
mock_platform.queue_send_message.return_value = "status_1"
mock_cli_manager.stop_all.side_effect = stop_cli
workflow = MessagingWorkflow(
mock_platform,
mock_cli_manager,
mock_session_store,
platform_name="telegram",
)
workflow._tree_queue = TreeQueueManager(cancellation_delayed_runner)
await workflow.handle_message(
incoming_message_factory(text="work", message_id="work_1")
)
await runner_started.wait()
close_task = asyncio.create_task(workflow.close())
try:
await asyncio.wait_for(cancellation_seen.wait(), timeout=1)
await asyncio.wait_for(cli_stop_seen.wait(), timeout=1)
mock_cli_manager.stop_all.assert_awaited_once()
assert close_task.done() is False
assert workflow.tree_queue.task_count() == 1
mock_session_store.flush_pending_save.assert_not_called()
release_cleanup.set()
await asyncio.wait_for(close_task, timeout=1)
assert workflow.tree_queue.task_count() == 0
mock_session_store.flush_pending_save.assert_called_once()
finally:
release_cleanup.set()
if not close_task.done():
await asyncio.wait_for(close_task, timeout=1)
@pytest.mark.asyncio
async def test_node_runner_success_uses_claim_and_semantic_completion(
handler, mock_cli_manager, mock_platform, mock_session_store
):
claim = _claim(prompt="say hello")
session = _session(
[
{
"type": "assistant",
"message": {
"content": [
{"type": "thinking", "thinking": "Let me think"},
{"type": "text", "text": "Hello world"},
]
},
},
{"type": "exit", "code": 0},
]
)
mock_cli_manager.get_or_create_session.return_value = (
session,
"session_1",
False,
)
snapshot = _snapshot()
with patch.object(
handler.tree_queue,
"complete_claim",
AsyncMock(return_value=snapshot),
) as complete_claim:
await handler.node_runner.process_node(claim)
complete_claim.assert_awaited_once_with(claim, "session_1")
mock_session_store.save_tree_snapshot.assert_called_once_with(snapshot)
rendered = mock_platform.queue_edit_message.call_args_list[-1].args[2]
assert "✅ *Complete*" in rendered
assert "Hello world" in rendered
mock_cli_manager.get_or_create_session.assert_awaited_once_with(session_id=None)
assert session.start_task.call_args.args == ("say hello",)
assert session.start_task.call_args.kwargs == {
"session_id": None,
"fork_session": False,
}
@pytest.mark.asyncio
async def test_node_runner_uses_claim_parent_session_for_fork(
handler, mock_cli_manager
):
claim = _claim(parent_session_id="parent_session")
session = _session([{"type": "exit", "code": 0}])
mock_cli_manager.get_or_create_session.return_value = (
session,
"child_session",
False,
)
await handler.node_runner.process_node(claim)
mock_cli_manager.get_or_create_session.assert_awaited_once_with(
session_id="parent_session"
)
assert session.start_task.call_args.kwargs == {
"session_id": "parent_session",
"fork_session": True,
}
@pytest.mark.asyncio
async def test_session_info_records_real_session_through_manager(
handler, mock_cli_manager, mock_session_store
):
claim = _claim()
session = _session(
[
{"type": "session_info", "session_id": "real_session"},
{"type": "exit", "code": 0},
]
)
mock_cli_manager.get_or_create_session.return_value = (
session,
"temporary_session",
True,
)
record_snapshot = _snapshot("record")
complete_snapshot = _snapshot("complete")
with (
patch.object(
handler.tree_queue,
"record_session",
AsyncMock(return_value=record_snapshot),
) as record_session,
patch.object(
handler.tree_queue,
"complete_claim",
AsyncMock(return_value=complete_snapshot),
) as complete_claim,
):
await handler.node_runner.process_node(claim)
mock_cli_manager.register_real_session_id.assert_awaited_once_with(
"temporary_session", "real_session"
)
record_session.assert_awaited_once_with(claim, "real_session")
complete_claim.assert_awaited_once_with(claim, "real_session")
assert mock_session_store.save_tree_snapshot.call_args_list == [
((record_snapshot,), {}),
((complete_snapshot,), {}),
]
@pytest.mark.asyncio
async def test_session_info_rejects_unregistered_real_session_without_recording_it(
handler,
mock_cli_manager,
) -> None:
from free_claude_code.messaging.node_event_pipeline import (
handle_session_info_event,
)
claim = _claim()
record_session = AsyncMock()
mock_cli_manager.register_real_session_id.return_value = False
with pytest.raises(
RuntimeError,
match=r"^Managed Claude session registration failed\.$",
) as raised:
await handle_session_info_event(
{"type": "session_info", "session_id": "real_session"},
claim,
None,
"temporary_session",
cli_manager=mock_cli_manager,
record_session=record_session,
)
assert "real_session" not in str(raised.value)
assert "temporary_session" not in str(raised.value)
record_session.assert_not_awaited()
@pytest.mark.asyncio
async def test_session_limit_failure_uses_non_propagating_claim_failure(
handler, mock_cli_manager, mock_platform
):
claim = _claim()
mock_cli_manager.get_or_create_session.side_effect = RuntimeError("session limit")
result = FailureResult(
affected=(claim.node,),
queue_update=None,
snapshot=_snapshot(),
)
with patch.object(
handler.tree_queue,
"fail_claim",
AsyncMock(return_value=result),
) as fail_claim:
await handler.node_runner.process_node(claim)
fail_claim.assert_awaited_once_with(claim, propagate=False)
rendered = mock_platform.queue_edit_message.call_args_list[-1].args[2]
assert "Session limit reached" in rendered
@pytest.mark.asyncio
async def test_non_exit_error_defers_child_failure_until_stream_ends(
handler, mock_cli_manager, mock_platform, mock_session_store
):
claim = _claim()
session = _session([{"type": "error", "error": {"message": "CLI crashed"}}])
mock_cli_manager.get_or_create_session.return_value = (
session,
"session_1",
False,
)
child = NodeUiTarget(
scope=_SCOPE,
node_id="child",
status_message_id="status_child",
)
snapshot = _snapshot()
result = FailureResult(
affected=(claim.node, child),
queue_update=None,
snapshot=snapshot,
)
with patch.object(
handler.tree_queue,
"fail_claim",
AsyncMock(return_value=result),
) as fail_claim:
await handler.node_runner.process_node(claim)
await asyncio.sleep(0)
assert fail_claim.await_args_list == [
call(claim, propagate=False),
call(claim, propagate=True),
]
assert mock_session_store.save_tree_snapshot.call_args_list == [
call(snapshot),
call(snapshot),
]
rendered = "\n".join(
call.args[2] for call in mock_platform.queue_edit_message.call_args_list
)
assert "❌ *Error*" in rendered
assert "CLI crashed" in rendered
assert "Parent task failed" in rendered
@pytest.mark.asyncio
async def test_provider_error_exit_does_not_mask_or_complete(
handler, mock_cli_manager, mock_platform
):
claim = _claim()
provider_error = "API Error: Request rejected (429)\nProvider rate limit reached."
session = _session(
[
{"type": "error", "error": {"message": provider_error}},
{"type": "exit", "code": 1},
]
)
mock_cli_manager.get_or_create_session.return_value = (
session,
"session_1",
False,
)
failure = FailureResult(
affected=(claim.node,),
queue_update=None,
snapshot=_snapshot(),
)
with (
patch.object(
handler.tree_queue,
"fail_claim",
AsyncMock(return_value=failure),
) as fail_claim,
patch.object(
handler.tree_queue,
"complete_claim",
AsyncMock(),
) as complete_claim,
):
await handler.node_runner.process_node(claim)
assert fail_claim.await_args_list == [
call(claim, propagate=False),
call(claim, propagate=True),
]
complete_claim.assert_not_awaited()
rendered = mock_platform.queue_edit_message.call_args_list[-1].args[2]
assert "API Error: Request rejected" in rendered
assert "Process exited with code" not in rendered
assert "✅ *Complete*" not in rendered
@pytest.mark.asyncio
async def test_success_exit_still_renders_complete_after_non_exit_error(
handler, mock_cli_manager, mock_platform
):
claim = _claim()
session = _session(
[
{"type": "error", "error": {"message": "recoverable warning"}},
{"type": "exit", "code": 0},
]
)
mock_cli_manager.get_or_create_session.return_value = (
session,
"session_1",
False,
)
failure = FailureResult(
affected=(claim.node,),
queue_update=None,
snapshot=_snapshot(),
)
with (
patch.object(
handler.tree_queue,
"fail_claim",
AsyncMock(return_value=failure),
) as fail_claim,
patch.object(
handler.tree_queue,
"complete_claim",
AsyncMock(return_value=_snapshot()),
) as complete_claim,
):
await handler.node_runner.process_node(claim)
fail_claim.assert_awaited_once_with(
claim,
propagate=False,
)
complete_claim.assert_awaited_once_with(claim, "session_1")
assert (
"✅ *Complete*" in mock_platform.queue_edit_message.call_args_list[-1].args[2]
)
@pytest.mark.asyncio
async def test_unexpected_runner_exception_uses_detailed_task_failed_ui(
handler, mock_cli_manager, mock_platform
):
claim = _claim()
async def failing_start(*args, **kwargs):
raise ValueError("runner exploded")
if False:
yield {}
session = MagicMock()
session.start_task = failing_start
mock_cli_manager.get_or_create_session.return_value = (
session,
"session_1",
False,
)
failure = FailureResult(
affected=(claim.node,),
queue_update=None,
snapshot=_snapshot(),
)
with patch.object(
handler.tree_queue,
"fail_claim",
AsyncMock(return_value=failure),
) as fail_claim:
await handler.node_runner.process_node(claim)
fail_claim.assert_awaited_once_with(
claim,
propagate=True,
)
rendered = mock_platform.queue_edit_message.call_args_list[-1].args[2]
assert "Task Failed" in rendered
assert "runner exploded" in rendered
@pytest.mark.asyncio
async def test_stop_cancellation_preserves_partial_transcript(
handler, mock_cli_manager, mock_platform
):
claim = _claim(prompt="work")
started = asyncio.Event()
async def start_task(*args, **kwargs):
yield {
"type": "assistant",
"message": {"content": [{"type": "text", "text": "partial answer"}]},
}
started.set()
await asyncio.sleep(60)
session = MagicMock()
session.start_task = start_task
mock_cli_manager.get_or_create_session.return_value = (
session,
"session_1",
False,
)
failure = FailureResult(
affected=(claim.node,),
queue_update=None,
snapshot=_snapshot(),
)
with patch.object(
handler.tree_queue,
"fail_claim",
AsyncMock(return_value=failure),
) as fail_claim:
task = asyncio.create_task(handler.node_runner.process_node(claim))
await started.wait()
task.cancel(CancellationReason.STOP)
await task
fail_claim.assert_awaited_once_with(
claim,
propagate=False,
)
rendered = mock_platform.queue_edit_message.call_args_list[-1].args[2]
assert "partial answer" in rendered
assert "⏹ *Stopped\\.*" in rendered
assert rendered.index("partial answer") < rendered.index("⏹ *Stopped\\.*")
@pytest.mark.asyncio
async def test_global_clear_command_deletes_returned_ids(
handler, mock_platform, incoming_message_factory
):
handler.clear_chat = AsyncMock(return_value=frozenset({"100", "101"}))
incoming = incoming_message_factory(
text="/clear",
chat_id="chat_1",
message_id="150",
)
await handler.handle_message(incoming)
handler.clear_chat.assert_awaited_once_with("telegram", "chat_1")
mock_platform.queue_delete_messages.assert_awaited_once_with(
"chat_1",
["150", "101", "100"],
fire_and_forget=False,
)
mock_platform.queue_send_message.assert_not_awaited()
handler.session_store.record_message_id.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"failure",
(RuntimeError("clear failed"), asyncio.CancelledError()),
ids=("failure", "cancellation"),
)
async def test_interrupted_global_clear_records_its_deferred_command_id(
handler,
mock_platform,
mock_session_store,
incoming_message_factory,
failure: BaseException,
) -> None:
handler.clear_chat = AsyncMock(side_effect=failure)
incoming = incoming_message_factory(
text="/clear",
chat_id="chat_1",
message_id="150",
)
with pytest.raises(type(failure)):
await handler.handle_message(incoming)
mock_session_store.record_message_id.assert_called_once_with(
incoming.platform,
incoming.chat_id,
incoming.message_id,
"in",
"command",
)
mock_platform.queue_delete_messages.assert_not_awaited()
@pytest.mark.asyncio
async def test_startup_notice_is_published_and_recorded_for_clear(
handler,
mock_platform,
mock_session_store,
) -> None:
mock_platform.queue_send_message.return_value = "startup_1"
await handler.publish_startup_notice(_startup_notice())
mock_platform.queue_send_message.assert_awaited_once_with(
_SCOPE.chat_id,
"🚀 *Claude Code Proxy is online\\!* \\(Bot API\\)",
parse_mode="MarkdownV2",
fire_and_forget=False,
)
mock_session_store.record_message_id.assert_called_once_with(
_SCOPE.platform,
_SCOPE.chat_id,
"startup_1",
"out",
"startup",
)
mock_platform.queue_delete_messages.assert_not_awaited()
@pytest.mark.asyncio
async def test_startup_notice_failure_is_nonfatal_and_records_nothing(
handler,
mock_platform,
mock_session_store,
) -> None:
mock_platform.queue_send_message.side_effect = RuntimeError("unavailable")
await handler.publish_startup_notice(_startup_notice())
mock_session_store.record_message_id.assert_not_called()
mock_platform.queue_delete_messages.assert_not_awaited()
@pytest.mark.asyncio
async def test_startup_notice_without_delivery_receipt_records_nothing(
handler,
mock_platform,
mock_session_store,
) -> None:
mock_platform.queue_send_message.return_value = None
await handler.publish_startup_notice(_startup_notice())
mock_session_store.record_message_id.assert_not_called()
mock_platform.queue_delete_messages.assert_not_awaited()
@pytest.mark.asyncio
async def test_startup_notice_record_failure_is_compensated_outside_state_lock(
handler,
mock_platform,
mock_session_store,
) -> None:
mock_platform.queue_send_message.return_value = "startup_1"
mock_session_store.record_message_id.side_effect = OSError("store unavailable")
async def delete_notice(*args, **kwargs) -> None:
assert not handler._state_lock.locked()
mock_platform.queue_delete_messages.side_effect = delete_notice
await handler.publish_startup_notice(_startup_notice())
mock_session_store.record_message_id.assert_called_once_with(
_SCOPE.platform,
_SCOPE.chat_id,
"startup_1",
"out",
"startup",
)
mock_platform.queue_delete_messages.assert_awaited_once_with(
_SCOPE.chat_id,
["startup_1"],
fire_and_forget=False,
)
mock_session_store.forget_tracked_message_ids.assert_called_once_with(
_SCOPE.platform,
_SCOPE.chat_id,
{"startup_1"},
)
@pytest.mark.asyncio
async def test_failed_startup_notice_compensation_restores_clear_ownership(
handler,
mock_platform,
mock_session_store,
) -> None:
mock_platform.queue_send_message.return_value = "startup_1"
mock_session_store.record_message_id.side_effect = (
OSError("store unavailable"),
None,
)
mock_platform.queue_delete_messages.side_effect = RuntimeError("delete unavailable")
await handler.publish_startup_notice(_startup_notice())
assert mock_session_store.record_message_id.call_count == 2
assert mock_session_store.record_message_id.call_args_list == [
call(
_SCOPE.platform,
_SCOPE.chat_id,
"startup_1",
"out",
"startup",
),
call(
_SCOPE.platform,
_SCOPE.chat_id,
"startup_1",
"out",
"startup",
),
]
mock_session_store.forget_tracked_message_ids.assert_not_called()
@pytest.mark.asyncio
async def test_startup_notice_publication_propagates_cancellation(
handler,
mock_platform,
mock_session_store,
) -> None:
send_started = asyncio.Event()
send_cancelled = asyncio.Event()
async def send_notice(*args, **kwargs) -> None:
send_started.set()
try:
await asyncio.Event().wait()
finally:
send_cancelled.set()
mock_platform.queue_send_message.side_effect = send_notice
task = asyncio.create_task(handler.publish_startup_notice(_startup_notice()))
await send_started.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
await send_cancelled.wait()
mock_session_store.record_message_id.assert_not_called()
mock_platform.queue_delete_messages.assert_not_awaited()
@pytest.mark.asyncio
async def test_startup_notice_cancellation_after_receipt_finishes_compensation(
handler,
mock_platform,
mock_session_store,
) -> None:
send_started = asyncio.Event()
release_send = asyncio.Event()
finalizer_started = asyncio.Event()
async def send_notice(*args, **kwargs) -> str:
send_started.set()
await release_send.wait()
return "startup_1"
original_finalize = handler._finalize_startup_notice
async def finalize_notice(*args, **kwargs) -> None:
finalizer_started.set()
await original_finalize(*args, **kwargs)
mock_platform.queue_send_message.side_effect = send_notice
with patch.object(
handler,
"_finalize_startup_notice",
side_effect=finalize_notice,
):
task = asyncio.create_task(handler.publish_startup_notice(_startup_notice()))
await send_started.wait()
await handler._state_lock.acquire()
try:
release_send.set()
await finalizer_started.wait()
finally:
handler._state_lock.release()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
mock_session_store.record_message_id.assert_not_called()
mock_platform.queue_delete_messages.assert_awaited_once_with(
_SCOPE.chat_id,
["startup_1"],
fire_and_forget=False,
)
mock_session_store.forget_tracked_message_ids.assert_called_once_with(
_SCOPE.platform,
_SCOPE.chat_id,
{"startup_1"},
)
@pytest.mark.asyncio
async def test_concurrent_global_clear_does_not_wait_for_startup_delivery(
mock_platform,
mock_cli_manager,
tmp_path,
) -> None:
store = SessionStore(storage_path=str(tmp_path / "sessions.json"))
workflow = MessagingWorkflow(
mock_platform,
mock_cli_manager,
store,
platform_name="telegram",
voice_cancellation=mock_platform,
)
send_started = asyncio.Event()
release_send = asyncio.Event()
async def send_notice(*args, **kwargs) -> str:
send_started.set()
await release_send.wait()
return "startup_1"
mock_platform.queue_send_message.side_effect = send_notice
publish_task = asyncio.create_task(
workflow.publish_startup_notice(_startup_notice())
)
await send_started.wait()
clear_task = asyncio.create_task(
workflow.clear_chat(_SCOPE.platform, _SCOPE.chat_id)
)
try:
done, _pending = await asyncio.wait({clear_task}, timeout=1)
assert clear_task in done
assert clear_task.result() == frozenset()
finally:
release_send.set()
await asyncio.gather(publish_task, clear_task, return_exceptions=True)
assert store.get_tracked_message_ids_for_chat(_SCOPE.platform, _SCOPE.chat_id) == []
mock_platform.queue_delete_messages.assert_awaited_once_with(
_SCOPE.chat_id,
["startup_1"],
fire_and_forget=False,
)
@pytest.mark.asyncio
async def test_global_stop_does_not_wait_for_or_invalidate_startup_delivery(
handler,
mock_platform,
mock_session_store,
) -> None:
send_started = asyncio.Event()
release_send = asyncio.Event()
async def send_notice(*args, **kwargs) -> str:
send_started.set()
await release_send.wait()
return "startup_1"
mock_platform.queue_send_message.side_effect = send_notice
publish_task = asyncio.create_task(
handler.publish_startup_notice(_startup_notice())
)
await send_started.wait()
stop_task = asyncio.create_task(handler.stop_all_tasks())
try:
done, _pending = await asyncio.wait({stop_task}, timeout=1)
assert stop_task in done
stop_task.result()
finally:
release_send.set()
await asyncio.gather(publish_task, stop_task, return_exceptions=True)
mock_session_store.record_message_id.assert_called_once_with(
_SCOPE.platform,
_SCOPE.chat_id,
"startup_1",
"out",
"startup",
)
mock_platform.queue_delete_messages.assert_not_awaited()
@pytest.mark.asyncio
async def test_global_clear_precedes_concurrent_startup_notice_publication(
mock_platform,
mock_cli_manager,
tmp_path,
) -> None:
store = SessionStore(storage_path=str(tmp_path / "sessions.json"))
workflow = MessagingWorkflow(
mock_platform,
mock_cli_manager,
store,
platform_name="telegram",
voice_cancellation=mock_platform,
)
clear_started = asyncio.Event()
release_clear = asyncio.Event()
async def clear_trees(
scope: MessageScope, *, reason: CancellationReason
) -> CancellationResult:
assert reason is CancellationReason.CLEAR
clear_started.set()
await release_clear.wait()
return CancellationResult()
with patch.object(workflow.tree_queue, "clear_scope", side_effect=clear_trees):
clear_task = asyncio.create_task(
workflow.clear_chat(_SCOPE.platform, _SCOPE.chat_id)
)
await clear_started.wait()
publish_task = asyncio.create_task(
workflow.publish_startup_notice(_startup_notice())
)
await asyncio.sleep(0)
mock_platform.queue_send_message.assert_not_awaited()
release_clear.set()
assert await clear_task == frozenset()
await publish_task
assert store.get_tracked_message_ids_for_chat(_SCOPE.platform, _SCOPE.chat_id) == [
"msg_123"
]
mock_platform.queue_delete_messages.assert_not_awaited()
@pytest.mark.asyncio
async def test_clear_command_cannot_evict_startup_notice_at_managed_message_cap(
mock_platform,
mock_cli_manager,
incoming_message_factory,
tmp_path,
) -> None:
store = SessionStore(
storage_path=str(tmp_path / "sessions.json"),
managed_message_cap=1,
)
workflow = MessagingWorkflow(
mock_platform,
mock_cli_manager,
store,
platform_name="telegram",
voice_cancellation=mock_platform,
)
store.record_message_id(
_SCOPE.platform,
_SCOPE.chat_id,
"100",
"out",
"startup",
)
incoming = incoming_message_factory(
text="/clear",
chat_id=_SCOPE.chat_id,
message_id="101",
)
await workflow.handle_message(incoming)
mock_platform.queue_delete_messages.assert_awaited_once_with(
_SCOPE.chat_id,
["101", "100"],
fire_and_forget=False,
)
@pytest.mark.asyncio
async def test_clear_chat_is_fully_scoped_to_the_invoking_chat(
handler, mock_cli_manager, mock_session_store, incoming_message_factory
):
root_1 = incoming_message_factory(
text="one",
chat_id="chat_1",
message_id="100",
)
root_2 = incoming_message_factory(
text="two",
chat_id="chat_2",
message_id="200",
)
await handler.tree_queue.admit(root_1, "101")
await handler.tree_queue.admit(root_2, "201")
await _wait_for_idle(handler)
mock_session_store.get_tracked_message_ids_for_chat.return_value = ["42"]
mock_session_store.reset_mock()
mock_session_store.get_tracked_message_ids_for_chat.return_value = ["42"]
message_ids = await handler.clear_chat("telegram", "chat_1")
assert message_ids == frozenset({"42", "100", "101"})
assert "200" not in message_ids
assert handler.get_tree_count() == 1
assert await handler.tree_queue.get_node(root_2.scope, "200") is not None
mock_cli_manager.stop_all.assert_not_awaited()
mock_session_store.clear_scope.assert_called_once_with(_SCOPE)
@pytest.mark.asyncio
async def test_global_clear_cancels_and_deletes_only_current_chat_voices(
handler,
mock_platform,
mock_session_store,
) -> None:
events: list[str] = []
current = VoiceCancellationResult(
scope=_SCOPE,
voice_message_id="voice",
status_message_id="voice_status",
delete_message_ids=frozenset({"voice", "voice_status"}),
)
async def cancel_voices(scope: MessageScope) -> tuple[VoiceCancellationResult, ...]:
assert not handler._state_lock.locked()
assert scope == _SCOPE
events.append("voices")
return (current,)
async def clear_trees(
scope: MessageScope, *, reason: CancellationReason
) -> CancellationResult:
assert reason is CancellationReason.CLEAR
assert handler._state_lock.locked()
events.append("trees")
return CancellationResult()
mock_platform.cancel_pending_voices_in_scope.side_effect = cancel_voices
mock_session_store.get_tracked_message_ids_for_chat.return_value = ["stored"]
with patch.object(handler.tree_queue, "clear_scope", side_effect=clear_trees):
message_ids = await handler.clear_chat("telegram", "chat_1")
await asyncio.sleep(0)
assert events == ["voices", "trees"]
assert message_ids == frozenset({"stored", "voice", "voice_status"})
assert "other_voice" not in message_ids
assert "other_status" not in message_ids
mock_platform.queue_edit_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_global_clear_persists_after_tree_detach(
handler,
mock_cli_manager,
mock_session_store,
) -> None:
events: list[str] = []
async def clear_trees(
scope: MessageScope, *, reason: CancellationReason
) -> CancellationResult:
assert scope == _SCOPE
assert reason is CancellationReason.CLEAR
events.append("trees.clear_scope")
return CancellationResult()
mock_session_store.clear_scope.side_effect = lambda scope: events.append(
f"store.clear_scope:{scope.chat_id}"
)
with patch.object(handler.tree_queue, "clear_scope", side_effect=clear_trees):
result = await handler.clear_chat("telegram", "chat_1")
assert result == frozenset()
assert events == ["trees.clear_scope", "store.clear_scope:chat_1"]
mock_cli_manager.stop_all.assert_not_awaited()
@pytest.mark.asyncio
async def test_global_clear_finishes_cleanup_before_final_persistence_error_escapes(
handler,
mock_cli_manager,
mock_session_store,
) -> None:
events: list[str] = []
async def clear_trees(
scope: MessageScope, *, reason: CancellationReason
) -> CancellationResult:
assert scope == _SCOPE
assert reason is CancellationReason.CLEAR
events.append("trees.clear_scope")
return CancellationResult()
def fail_final_clear(scope: MessageScope) -> None:
assert scope == _SCOPE
events.append("store.clear_scope")
raise OSError("final clear failure")
mock_session_store.clear_scope.side_effect = fail_final_clear
with (
patch.object(handler.tree_queue, "clear_scope", side_effect=clear_trees),
pytest.raises(OSError, match="final clear failure"),
):
await handler.clear_chat("telegram", "chat_1")
assert events == ["trees.clear_scope", "store.clear_scope"]
mock_cli_manager.stop_all.assert_not_awaited()
@pytest.mark.asyncio
async def test_global_clear_preserves_tree_and_persistence_failures(
handler,
mock_cli_manager,
mock_session_store,
) -> None:
events: list[str] = []
persistence_error = OSError("final clear failure")
tree_error = RuntimeError("tree clear failure")
async def fail_tree_clear(
scope: MessageScope, *, reason: CancellationReason
) -> CancellationResult:
assert scope == _SCOPE
assert reason is CancellationReason.CLEAR
events.append("trees.clear_scope")
raise tree_error
def fail_final_clear(scope: MessageScope) -> None:
assert scope == _SCOPE
events.append("store.clear_scope")
raise persistence_error
mock_session_store.clear_scope.side_effect = fail_final_clear
with (
patch.object(handler.tree_queue, "clear_scope", side_effect=fail_tree_clear),
pytest.raises(ExceptionGroup) as raised,
):
await handler.clear_chat("telegram", "chat_1")
assert raised.value.exceptions == (tree_error, persistence_error)
assert events == ["trees.clear_scope", "store.clear_scope"]
mock_cli_manager.stop_all.assert_not_awaited()
@pytest.mark.asyncio
async def test_committed_global_clear_attempts_remaining_steps_after_tree_failure(
handler,
mock_cli_manager,
mock_session_store,
) -> None:
events: list[str] = []
tree_error = RuntimeError("tree clear failure")
async def fail_tree_clear(
scope: MessageScope, *, reason: CancellationReason
) -> CancellationResult:
assert scope == _SCOPE
assert reason is CancellationReason.CLEAR
events.append("trees.clear_scope")
raise tree_error
mock_session_store.clear_scope.side_effect = lambda scope: events.append(
f"store.clear_scope:{scope.chat_id}"
)
with (
patch.object(handler.tree_queue, "clear_scope", side_effect=fail_tree_clear),
pytest.raises(RuntimeError, match="tree clear failure") as raised,
):
await handler.clear_chat("telegram", "chat_1")
assert raised.value is tree_error
assert events == [
"trees.clear_scope",
"store.clear_scope:chat_1",
]
mock_cli_manager.stop_all.assert_not_awaited()
@pytest.mark.asyncio
async def test_cancelled_global_clear_finishes_owned_transaction_before_propagating(
handler,
mock_cli_manager,
mock_session_store,
incoming_message_factory,
):
root = incoming_message_factory(text="work", message_id="100")
await handler.tree_queue.admit(root, "101")
await _wait_for_idle(handler)
id_read_started = asyncio.Event()
release_id_read = asyncio.Event()
async def block_id_read(platform: str, chat_id: str) -> set[str]:
id_read_started.set()
await release_id_read.wait()
return {"100", "101"}
mock_session_store.reset_mock()
with patch.object(
handler.tree_queue,
"get_message_ids_for_chat",
new=block_id_read,
):
clear_task = asyncio.create_task(handler.clear_chat("telegram", root.chat_id))
await id_read_started.wait()
clear_task.cancel()
await asyncio.sleep(0)
assert not clear_task.done()
release_id_read.set()
with pytest.raises(asyncio.CancelledError):
await clear_task
assert handler._clear_generations[_SCOPE] == 1
assert handler.get_tree_count() == 0
mock_session_store.clear_scope.assert_called_once_with(_SCOPE)
mock_cli_manager.stop_all.assert_not_awaited()
@pytest.mark.asyncio
async def test_clear_with_mention_uses_same_global_command(
handler, mock_platform, incoming_message_factory
):
handler.clear_chat = AsyncMock(return_value=frozenset())
incoming = incoming_message_factory(
text="/clear@MyBot",
chat_id="chat_1",
message_id="10",
)
await handler.handle_message(incoming)
handler.clear_chat.assert_awaited_once_with("telegram", "chat_1")
mock_platform.queue_delete_messages.assert_awaited_once_with(
"chat_1",
["10"],
fire_and_forget=False,
)
@pytest.mark.asyncio
async def test_clear_continues_after_platform_delete_failure(
handler, mock_platform, incoming_message_factory
):
handler.clear_chat = AsyncMock(return_value=frozenset({"41", "42"}))
mock_platform.queue_delete_messages.side_effect = RuntimeError(
"platform rejected delete"
)
await handler.handle_message(
incoming_message_factory(text="/clear", message_id="150")
)
handler.clear_chat.assert_awaited_once()
mock_platform.queue_delete_messages.assert_awaited_once()
@pytest.mark.asyncio
async def test_reply_clear_status_preserves_prompt_and_persists_remaining_tree(
handler,
mock_platform,
mock_session_store,
incoming_message_factory,
):
root = incoming_message_factory(text="root", message_id="100")
child = incoming_message_factory(
text="child",
message_id="102",
reply_to_message_id="100",
)
await handler.tree_queue.admit(root, "101")
await _wait_for_idle(handler)
await handler.tree_queue.admit(child, "103", parent_reference_id="100")
await _wait_for_idle(handler)
mock_session_store.reset_mock()
deleted_ids: list[str] = []
async def capture_delete(chat_id, message_ids, fire_and_forget=True):
deleted_ids.extend(message_ids)
mock_platform.queue_delete_messages.side_effect = capture_delete
await handler.handle_message(
incoming_message_factory(
text="/clear",
message_id="150",
reply_to_message_id="103",
)
)
assert set(deleted_ids) == {"103", "150"}
assert "102" not in deleted_ids
assert "100" not in deleted_ids
assert "101" not in deleted_ids
cleared_prompt = await handler.tree_queue.get_node(root.scope, "102")
assert cleared_prompt is not None
assert cleared_prompt.state is MessageState.ERROR
assert cleared_prompt.session_id is None
assert await handler.tree_queue.get_node(root.scope, "100") is not None
mock_session_store.save_tree_snapshot.assert_called_once()
mock_session_store.record_message_id.assert_called_once_with(
"telegram",
"chat_1",
"150",
"in",
"command",
)
mock_session_store.forget_tracked_message_ids.assert_called_once_with(
"telegram",
"chat_1",
{"103", "150"},
)
@pytest.mark.asyncio
async def test_reply_clear_unknown_reports_nothing_to_clear(
handler, mock_platform, mock_session_store, incoming_message_factory
):
incoming = incoming_message_factory(
text="/clear",
message_id="150",
reply_to_message_id="999",
)
await handler.handle_message(incoming)
assert "Nothing to clear" in mock_platform.queue_send_message.call_args.args[1]
mock_session_store.record_message_id.assert_any_call(
incoming.platform,
incoming.chat_id,
incoming.message_id,
"in",
"command",
)
mock_session_store.clear_scope.assert_not_called()
@pytest.mark.asyncio
async def test_reply_clear_root_removes_tree_snapshot(
handler,
mock_platform,
mock_session_store,
incoming_message_factory,
):
root = incoming_message_factory(text="root", message_id="100")
await handler.tree_queue.admit(root, "101")
await _wait_for_idle(handler)
mock_session_store.reset_mock()
deleted_ids: list[str] = []
async def capture_delete(chat_id, message_ids, fire_and_forget=True):
deleted_ids.extend(message_ids)
mock_platform.queue_delete_messages.side_effect = capture_delete
await handler.handle_message(
incoming_message_factory(
text="/clear",
message_id="150",
reply_to_message_id="100",
)
)
assert set(deleted_ids) == {"100", "101", "150"}
mock_session_store.remove_tree_snapshot.assert_called_once_with(
TreeIdentity(scope=root.scope, root_id="100")
)
assert handler.get_tree_count() == 0
@pytest.mark.asyncio
async def test_late_cancelled_runner_cannot_save_or_render_after_chat_clear(
handler,
mock_platform,
mock_cli_manager,
mock_session_store,
incoming_message_factory,
):
started = asyncio.Event()
async def blocking_start(*args, **kwargs):
started.set()
await asyncio.sleep(60)
if False:
yield {}
session = MagicMock()
session.start_task = blocking_start
mock_cli_manager.get_or_create_session.return_value = (
session,
"pending_1",
True,
)
await handler.handle_message(
incoming_message_factory(text="work", message_id="100")
)
await started.wait()
mock_session_store.reset_mock()
mock_platform.queue_edit_message.reset_mock()
await handler.clear_chat("telegram", "chat_1")
mock_session_store.save_tree_snapshot.assert_not_called()
mock_session_store.clear_scope.assert_called_once_with(_SCOPE)
mock_platform.queue_edit_message.assert_not_awaited()
assert handler.get_tree_count() == 0
@pytest.mark.asyncio
async def test_global_clear_removes_snapshot_saved_during_detach_window(
tmp_path,
mock_platform,
mock_cli_manager,
incoming_message_factory,
):
runner_started = asyncio.Event()
release_runner = asyncio.Event()
id_read_started = asyncio.Event()
release_id_read = asyncio.Event()
async def finish_after_release(*args, **kwargs):
runner_started.set()
await release_runner.wait()
yield {"type": "exit", "code": 0}
session = MagicMock()
session.start_task = finish_after_release
mock_cli_manager.get_or_create_session.return_value = (
session,
"session_1",
False,
)
mock_platform.queue_send_message.return_value = "status-new"
store_path = tmp_path / "sessions.json"
store = SessionStore(storage_path=str(store_path))
workflow = MessagingWorkflow(
mock_platform,
mock_cli_manager,
store,
platform_name="telegram",
)
incoming = incoming_message_factory(text="work", message_id="new")
await workflow.handle_message(incoming)
await runner_started.wait()
get_ids = workflow.tree_queue.get_message_ids_for_chat
async def block_id_read(platform: str, chat_id: str) -> set[str]:
id_read_started.set()
await release_id_read.wait()
return await get_ids(platform, chat_id)
try:
with patch.object(
workflow.tree_queue,
"get_message_ids_for_chat",
new=block_id_read,
):
clear_task = asyncio.create_task(
workflow.clear_chat("telegram", incoming.chat_id)
)
await id_read_started.wait()
release_runner.set()
await _wait_for_idle(workflow)
assert not store.load_conversation_snapshot().is_empty
store.flush_pending_save()
assert (
not SessionStore(storage_path=str(store_path))
.load_conversation_snapshot()
.is_empty
)
release_id_read.set()
await clear_task
assert store.load_conversation_snapshot().is_empty
assert (
SessionStore(storage_path=str(store_path))
.load_conversation_snapshot()
.is_empty
)
finally:
release_runner.set()
release_id_read.set()
await workflow.close()
@pytest.mark.asyncio
async def test_global_clear_invalidates_inflight_prompt_without_waiting_for_status(
handler,
mock_platform,
incoming_message_factory,
):
status_send_started = asyncio.Event()
release_status_send = asyncio.Event()
async def block_status_send(*args, **kwargs):
status_send_started.set()
await release_status_send.wait()
return "status-new"
mock_platform.queue_send_message.side_effect = block_status_send
prompt_task = asyncio.create_task(
handler.handle_message(
incoming_message_factory(text="new prompt", message_id="new")
)
)
await status_send_started.wait()
clear_task = asyncio.create_task(handler.clear_chat("telegram", "chat_1"))
try:
await asyncio.wait_for(clear_task, timeout=1)
release_status_send.set()
await prompt_task
assert handler.get_tree_count() == 0
mock_platform.queue_delete_messages.assert_awaited_once_with(
"chat_1",
["status-new"],
fire_and_forget=False,
)
finally:
release_status_send.set()
if not prompt_task.done():
prompt_task.cancel()
if not clear_task.done():
clear_task.cancel()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status_message_id", "expected_deleted_ids"),
[
("101", {"100", "101", "150"}),
(None, {"100", "150"}),
],
)
async def test_reply_clear_pending_voice_cancels_and_reports(
handler,
mock_platform,
incoming_message_factory,
status_message_id,
expected_deleted_ids,
) -> None:
delete_message_ids = {"100"}
if status_message_id is not None:
delete_message_ids.add(status_message_id)
handler.clear_reply = AsyncMock(
return_value=ReplyClearResult(
delete_message_ids=frozenset(delete_message_ids),
tree_matched=False,
)
)
deleted_ids: list[str] = []
async def capture_delete(chat_id, message_ids, fire_and_forget=True):
deleted_ids.extend(message_ids)
mock_platform.queue_delete_messages.side_effect = capture_delete
incoming = incoming_message_factory(
text="/clear",
message_id="150",
reply_to_message_id="100",
)
await handler.handle_message(incoming)
handler.clear_reply.assert_awaited_once_with(incoming.scope, "100")
assert set(deleted_ids) == expected_deleted_ids
mock_platform.queue_send_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_reply_clear_deletes_owned_result_ids(
handler,
mock_platform,
incoming_message_factory,
) -> None:
handler.clear_reply = AsyncMock(
return_value=ReplyClearResult(
delete_message_ids=frozenset({"tree_status"}),
tree_matched=True,
)
)
deleted_ids: list[str] = []
async def capture_delete(_chat_id, message_ids, fire_and_forget=True):
deleted_ids.extend(message_ids)
mock_platform.queue_delete_messages.side_effect = capture_delete
incoming = incoming_message_factory(
text="/clear",
message_id="clear",
reply_to_message_id="voice",
)
await handler.handle_message(incoming)
handler.clear_reply.assert_awaited_once_with(incoming.scope, "voice")
assert set(deleted_ids) == {
"tree_status",
"clear",
}
assert "voice" not in deleted_ids
mock_platform.queue_send_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_reply_clear_joins_voice_then_unions_authoritative_branch_ids(
handler,
mock_platform,
) -> None:
events: list[str] = []
async def cancel_voice(*_args) -> VoiceCancellationResult:
events.append("voice")
return VoiceCancellationResult(
scope=_SCOPE,
voice_message_id="voice",
status_message_id="voice_status",
delete_message_ids=frozenset({"voice", "voice_status"}),
)
branch = MessageSubtreeRemovalResult(
cancellation=CancellationResult(),
removed_tree_identity=None,
delete_message_ids=frozenset({"tree_status"}),
tree_matched=True,
)
mock_platform.cancel_pending_voice.side_effect = cancel_voice
with patch.object(
handler.tree_queue,
"remove_message_subtree",
new_callable=AsyncMock,
return_value=branch,
) as remove_message_subtree:
result = await handler.clear_reply(_SCOPE, "voice")
await asyncio.sleep(0)
assert result == ReplyClearResult(
delete_message_ids=frozenset({"voice", "voice_status", "tree_status"}),
tree_matched=True,
)
assert events == ["voice"]
remove_message_subtree.assert_awaited_once_with(
_SCOPE,
"voice",
reason=CancellationReason.CLEAR,
)
mock_platform.queue_edit_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_cancelled_reply_clear_finishes_voice_only_owner_after_lock_wait(
handler,
mock_platform,
) -> None:
voice_returned = asyncio.Event()
async def cancel_voice(*_args) -> VoiceCancellationResult:
voice_returned.set()
return VoiceCancellationResult(
scope=_SCOPE,
voice_message_id="voice",
status_message_id="voice_status",
delete_message_ids=frozenset({"voice", "voice_status"}),
)
mock_platform.cancel_pending_voice.side_effect = cancel_voice
remove_subtree = AsyncMock(
return_value=MessageSubtreeRemovalResult(
cancellation=CancellationResult(),
removed_tree_identity=None,
delete_message_ids=frozenset(),
tree_matched=False,
)
)
await handler._state_lock.acquire()
try:
with patch.object(
handler.tree_queue,
"remove_message_subtree",
remove_subtree,
):
clear_task = asyncio.create_task(handler.clear_reply(_SCOPE, "voice"))
await voice_returned.wait()
await asyncio.sleep(0)
clear_task.cancel()
await asyncio.sleep(0)
assert not clear_task.done()
handler._state_lock.release()
with pytest.raises(asyncio.CancelledError):
await clear_task
finally:
if handler._state_lock.locked():
handler._state_lock.release()
await asyncio.sleep(0)
remove_subtree.assert_awaited_once_with(
_SCOPE,
"voice",
reason=CancellationReason.CLEAR,
)
mock_platform.queue_edit_message.assert_not_awaited()