2500 lines
74 KiB
Python
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()
|