LastNoob commited on
Commit
a2e43d0
·
1 Parent(s): 1774192

Add clear command functionality to message handler

Browse files

- Implemented handling of the `/clear` command to clear specific branches or entire trees based on message replies.
- Added tests for various scenarios of the clear command, including clearing branches, handling unknown replies, and clearing entire trees.
- Enhanced `TreeQueueManager` with methods to cancel branches and remove subtrees, ensuring proper state management in the session store.
- Updated `SessionStore` and `TreeRepository` to support removal of node mappings and trees, improving data integrity during clear operations.

README.md CHANGED
@@ -190,7 +190,7 @@ Control Claude Code remotely from Discord. Send tasks, watch live progress, and
190
  - Session persistence across server restarts
191
  - Live streaming of thinking tokens, tool calls, and results
192
  - Up to 10 concurrent Claude CLI sessions
193
- - Commands: `/stop` (cancel tasks), `/clear` (reset all sessions), `/stats`
194
 
195
  ### Setup
196
 
@@ -219,7 +219,7 @@ ALLOWED_DIR=C:/Users/yourname/projects
219
  uv run uvicorn server:app --host 0.0.0.0 --port 8082
220
  ```
221
 
222
- 5. **Invite the bot** to your server (OAuth2 → URL Generator, scopes: `bot`, permissions: Read Messages, Send Messages, Manage Messages, Read Message History). Send a message in an allowed channel with a task. Claude responds with thinking tokens, tool calls as they execute, and the final result. Reply `/stop` to a running task to cancel it.
223
 
224
  ### Telegram (Alternative)
225
 
 
190
  - Session persistence across server restarts
191
  - Live streaming of thinking tokens, tool calls, and results
192
  - Up to 10 concurrent Claude CLI sessions
193
+ - Commands: `/stop` (cancel tasks; reply to a message to stop only that task), `/clear` (standalone: reset all sessions; reply to a message to clear that branch downwards), `/stats`
194
 
195
  ### Setup
196
 
 
219
  uv run uvicorn server:app --host 0.0.0.0 --port 8082
220
  ```
221
 
222
+ 5. **Invite the bot** to your server (OAuth2 → URL Generator, scopes: `bot`, permissions: Read Messages, Send Messages, Manage Messages, Read Message History). Send a message in an allowed channel with a task. Claude responds with thinking tokens, tool calls as they execute, and the final result. Reply to messages to cancel tasks or clear branches (see Commands above).
223
 
224
  ### Telegram (Alternative)
225
 
messaging/handler.py CHANGED
@@ -802,15 +802,126 @@ class ClaudeMessageHandler:
802
  incoming.platform, incoming.chat_id, msg_id, "command"
803
  )
804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
805
  async def _handle_clear_command(self, incoming: IncomingMessage) -> None:
806
  """
807
- Handle /clear global command.
808
 
809
- Order:
810
- 1. Stop all pending/in-progress tasks.
811
- 2. Best-effort delete tracked chat messages for this chat.
812
- 3. Clear sessions.json (entire store) and reset in-memory queue state.
813
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814
  # 1) Stop tasks first (ensures no more work is running).
815
  await self.stop_all_tasks()
816
 
@@ -853,48 +964,7 @@ class ClaudeMessageHandler:
853
  if incoming.message_id is not None:
854
  msg_ids.add(str(incoming.message_id))
855
 
856
- def _as_int(s: str) -> int | None:
857
- try:
858
- return int(str(s))
859
- except Exception:
860
- return None
861
-
862
- numeric: list[tuple[int, str]] = []
863
- non_numeric: list[str] = []
864
- for mid in msg_ids:
865
- n = _as_int(mid)
866
- if n is None:
867
- non_numeric.append(mid)
868
- else:
869
- numeric.append((n, mid))
870
-
871
- numeric.sort(reverse=True)
872
- ordered = [mid for _, mid in numeric] + non_numeric
873
-
874
- # If platform supports batch deletes, prefer it.
875
- batch_fn = getattr(self.platform, "queue_delete_messages", None)
876
- if callable(batch_fn):
877
- try:
878
- # Telegram supports up to 100 per request.
879
- CHUNK = 100
880
- for i in range(0, len(ordered), CHUNK):
881
- chunk = ordered[i : i + CHUNK]
882
- await batch_fn(incoming.chat_id, chunk, fire_and_forget=False)
883
- except Exception as e:
884
- logger.debug(f"/clear batch delete failed: {type(e).__name__}: {e}")
885
- else:
886
- for mid in ordered:
887
- try:
888
- await self.platform.queue_delete_message(
889
- incoming.chat_id,
890
- mid,
891
- fire_and_forget=False,
892
- )
893
- except Exception as e:
894
- # Deleting is best-effort; platform adapters also treat common cases as no-op.
895
- logger.debug(
896
- f"/clear delete failed for msg {mid}: {type(e).__name__}: {e}"
897
- )
898
 
899
  # 3) Clear persistent state and reset in-memory queue/tree state.
900
  try:
 
802
  incoming.platform, incoming.chat_id, msg_id, "command"
803
  )
804
 
805
+ async def _handle_clear_branch(
806
+ self, incoming: IncomingMessage, branch_root_id: str
807
+ ) -> None:
808
+ """
809
+ Clear a branch (replied-to node + all descendants).
810
+
811
+ Order: cancel tasks, delete messages, remove branch, update session store.
812
+ """
813
+ tree = self.tree_queue.get_tree_for_node(branch_root_id)
814
+ if not tree:
815
+ return
816
+
817
+ # 1) Cancel branch tasks (no stop_all)
818
+ cancelled = await self.tree_queue.cancel_branch(branch_root_id)
819
+ self._update_cancelled_nodes_ui(cancelled)
820
+
821
+ # 2) Collect message IDs from branch nodes only
822
+ msg_ids: set[str] = set()
823
+ branch_ids = tree.get_descendants(branch_root_id)
824
+ for nid in branch_ids:
825
+ node = tree.get_node(nid)
826
+ if node:
827
+ if node.incoming.message_id:
828
+ msg_ids.add(str(node.incoming.message_id))
829
+ if node.status_message_id:
830
+ msg_ids.add(str(node.status_message_id))
831
+ if incoming.message_id:
832
+ msg_ids.add(str(incoming.message_id))
833
+
834
+ # 3) Delete messages (best-effort)
835
+ await self._delete_message_ids(incoming.chat_id, msg_ids)
836
+
837
+ # 4) Remove branch from tree
838
+ removed, root_id, removed_entire_tree = await self.tree_queue.remove_branch(
839
+ branch_root_id
840
+ )
841
+
842
+ # 5) Update session store
843
+ try:
844
+ self.session_store.remove_node_mappings([n.node_id for n in removed])
845
+ if removed_entire_tree:
846
+ self.session_store.remove_tree(root_id)
847
+ else:
848
+ updated_tree = self.tree_queue.get_tree(root_id)
849
+ if updated_tree:
850
+ self.session_store.save_tree(root_id, updated_tree.to_dict())
851
+ except Exception as e:
852
+ logger.warning(f"Failed to update session store after branch clear: {e}")
853
+
854
+ async def _delete_message_ids(self, chat_id: str, msg_ids: set[str]) -> None:
855
+ """Best-effort delete messages by ID. Sorts numeric IDs descending."""
856
+ if not msg_ids:
857
+ return
858
+
859
+ def _as_int(s: str) -> int | None:
860
+ try:
861
+ return int(str(s))
862
+ except Exception:
863
+ return None
864
+
865
+ numeric: list[tuple[int, str]] = []
866
+ non_numeric: list[str] = []
867
+ for mid in msg_ids:
868
+ n = _as_int(mid)
869
+ if n is None:
870
+ non_numeric.append(mid)
871
+ else:
872
+ numeric.append((n, mid))
873
+ numeric.sort(reverse=True)
874
+ ordered = [mid for _, mid in numeric] + non_numeric
875
+
876
+ batch_fn = getattr(self.platform, "queue_delete_messages", None)
877
+ if callable(batch_fn):
878
+ try:
879
+ CHUNK = 100
880
+ for i in range(0, len(ordered), CHUNK):
881
+ chunk = ordered[i : i + CHUNK]
882
+ await batch_fn(chat_id, chunk, fire_and_forget=False)
883
+ except Exception as e:
884
+ logger.debug(f"Batch delete failed: {type(e).__name__}: {e}")
885
+ else:
886
+ for mid in ordered:
887
+ try:
888
+ await self.platform.queue_delete_message(
889
+ chat_id, mid, fire_and_forget=False
890
+ )
891
+ except Exception as e:
892
+ logger.debug(
893
+ f"Delete failed for msg {mid}: {type(e).__name__}: {e}"
894
+ )
895
+
896
  async def _handle_clear_command(self, incoming: IncomingMessage) -> None:
897
  """
898
+ Handle /clear command.
899
 
900
+ Reply-scoped: reply to a message to clear that branch (node + descendants).
901
+ Standalone: global clear (stop all, delete all chat messages, reset store).
 
 
902
  """
903
+ if incoming.is_reply() and incoming.reply_to_message_id:
904
+ reply_id = incoming.reply_to_message_id
905
+ tree = self.tree_queue.get_tree_for_node(reply_id)
906
+ branch_root_id = (
907
+ self.tree_queue.resolve_parent_node_id(reply_id) if tree else None
908
+ )
909
+ if not branch_root_id:
910
+ msg_id = await self.platform.queue_send_message(
911
+ incoming.chat_id,
912
+ self._format_status(
913
+ "🗑", "Cleared.", "Nothing to clear for that message."
914
+ ),
915
+ fire_and_forget=False,
916
+ )
917
+ self._record_outgoing_message(
918
+ incoming.platform, incoming.chat_id, msg_id, "command"
919
+ )
920
+ return
921
+ await self._handle_clear_branch(incoming, branch_root_id)
922
+ return
923
+
924
+ # Global clear
925
  # 1) Stop tasks first (ensures no more work is running).
926
  await self.stop_all_tasks()
927
 
 
964
  if incoming.message_id is not None:
965
  msg_ids.add(str(incoming.message_id))
966
 
967
+ await self._delete_message_ids(incoming.chat_id, msg_ids)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
968
 
969
  # 3) Clear persistent state and reset in-memory queue/tree state.
970
  try:
messaging/session.py CHANGED
@@ -288,6 +288,22 @@ class SessionStore:
288
  self._node_to_tree[node_id] = root_id
289
  self._schedule_save()
290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  def get_all_trees(self) -> Dict[str, dict]:
292
  """Get all stored trees (public accessor)."""
293
  with self._lock:
 
288
  self._node_to_tree[node_id] = root_id
289
  self._schedule_save()
290
 
291
+ def remove_node_mappings(self, node_ids: List[str]) -> None:
292
+ """Remove node IDs from the node-to-tree mapping."""
293
+ with self._lock:
294
+ for nid in node_ids:
295
+ self._node_to_tree.pop(nid, None)
296
+ self._schedule_save()
297
+
298
+ def remove_tree(self, root_id: str) -> None:
299
+ """Remove a tree and all its node mappings from the store."""
300
+ with self._lock:
301
+ tree_data = self._trees.pop(root_id, None)
302
+ if tree_data:
303
+ for node_id in tree_data.get("nodes", {}).keys():
304
+ self._node_to_tree.pop(node_id, None)
305
+ self._schedule_save()
306
+
307
  def get_all_trees(self) -> Dict[str, dict]:
308
  """Get all stored trees (public accessor)."""
309
  with self._lock:
messaging/tree_data.py CHANGED
@@ -386,3 +386,48 @@ class MessageTree:
386
  """Find the node that has this status message ID (O(1) lookup)."""
387
  node_id = self._status_to_node.get(status_msg_id)
388
  return self._nodes.get(node_id) if node_id else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  """Find the node that has this status message ID (O(1) lookup)."""
387
  node_id = self._status_to_node.get(status_msg_id)
388
  return self._nodes.get(node_id) if node_id else None
389
+
390
+ def get_descendants(self, node_id: str) -> List[str]:
391
+ """
392
+ Get node_id and all descendant IDs (subtree).
393
+
394
+ Returns:
395
+ List of node IDs including the given node.
396
+ """
397
+ if node_id not in self._nodes:
398
+ return []
399
+ result = [node_id]
400
+ node = self._nodes[node_id]
401
+ for child_id in node.children_ids:
402
+ result.extend(self.get_descendants(child_id))
403
+ return result
404
+
405
+ def remove_branch(self, branch_root_id: str) -> List[MessageNode]:
406
+ """
407
+ Remove a subtree (branch_root and all descendants) from the tree.
408
+
409
+ Updates parent's children_ids. Caller must hold lock for consistency.
410
+ Does not acquire lock internally.
411
+
412
+ Returns:
413
+ List of removed nodes.
414
+ """
415
+ if branch_root_id not in self._nodes:
416
+ return []
417
+
418
+ parent = self.get_parent(branch_root_id)
419
+ removed = []
420
+ for nid in self.get_descendants(branch_root_id):
421
+ node = self._nodes.get(nid)
422
+ if node:
423
+ removed.append(node)
424
+ del self._nodes[nid]
425
+ del self._status_to_node[node.status_message_id]
426
+
427
+ if parent and branch_root_id in parent.children_ids:
428
+ parent.children_ids = [
429
+ c for c in parent.children_ids if c != branch_root_id
430
+ ]
431
+
432
+ logger.debug(f"Removed branch {branch_root_id} ({len(removed)} nodes)")
433
+ return removed
messaging/tree_queue.py CHANGED
@@ -368,6 +368,75 @@ class TreeQueueManager:
368
  """Register a node ID to a tree (for external mapping)."""
369
  self._repository.register_node(node_id, root_id)
370
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  def to_dict(self) -> dict:
372
  """Serialize all trees."""
373
  return self._repository.to_dict()
 
368
  """Register a node ID to a tree (for external mapping)."""
369
  self._repository.register_node(node_id, root_id)
370
 
371
+ async def cancel_branch(self, branch_root_id: str) -> List[MessageNode]:
372
+ """
373
+ Cancel all PENDING/IN_PROGRESS nodes in the subtree (branch_root + descendants).
374
+
375
+ Does not call cli_manager.stop_all(). Returns list of cancelled nodes.
376
+ """
377
+ tree = self._repository.get_tree_for_node(branch_root_id)
378
+ if not tree:
379
+ return []
380
+
381
+ branch_ids = set(tree.get_descendants(branch_root_id))
382
+ cancelled: List[MessageNode] = []
383
+
384
+ async with tree.with_lock():
385
+ for nid in branch_ids:
386
+ node = tree.get_node(nid)
387
+ if not node or node.state in (
388
+ MessageState.COMPLETED,
389
+ MessageState.ERROR,
390
+ ):
391
+ continue
392
+
393
+ if tree.is_current_node(nid):
394
+ self._processor.cancel_current(tree)
395
+ node.state = MessageState.ERROR
396
+ node.error_message = "Cancelled by user"
397
+ node.completed_at = datetime.now(timezone.utc)
398
+ cancelled.append(node)
399
+ else:
400
+ tree.remove_from_queue(nid)
401
+ node.state = MessageState.ERROR
402
+ node.error_message = "Cancelled by user"
403
+ node.completed_at = datetime.now(timezone.utc)
404
+ cancelled.append(node)
405
+
406
+ if cancelled:
407
+ logger.info(f"Cancelled {len(cancelled)} nodes in branch {branch_root_id}")
408
+ return cancelled
409
+
410
+ async def remove_branch(
411
+ self, branch_root_id: str
412
+ ) -> tuple[List[MessageNode], str, bool]:
413
+ """
414
+ Remove a branch (subtree) from the tree.
415
+
416
+ If branch_root is the tree root, removes the entire tree.
417
+
418
+ Returns:
419
+ (removed_nodes, root_id, removed_entire_tree)
420
+ """
421
+ tree = self._repository.get_tree_for_node(branch_root_id)
422
+ if not tree:
423
+ return ([], "", False)
424
+
425
+ root_id = tree.root_id
426
+
427
+ if branch_root_id == root_id:
428
+ cancelled = self.cancel_tree(root_id)
429
+ removed_tree = self._repository.remove_tree(root_id)
430
+ if removed_tree:
431
+ return (removed_tree.all_nodes(), root_id, True)
432
+ return (cancelled, root_id, True)
433
+
434
+ async with tree.with_lock():
435
+ removed = tree.remove_branch(branch_root_id)
436
+
437
+ self._repository.unregister_nodes([n.node_id for n in removed])
438
+ return (removed, root_id, False)
439
+
440
  def to_dict(self) -> dict:
441
  """Serialize all trees."""
442
  return self._repository.to_dict()
messaging/tree_repository.py CHANGED
@@ -129,6 +129,26 @@ class TreeRepository:
129
  """Get all tree root IDs."""
130
  return list(self._trees.keys())
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  def to_dict(self) -> dict:
133
  """Serialize all trees."""
134
  return {
 
129
  """Get all tree root IDs."""
130
  return list(self._trees.keys())
131
 
132
+ def unregister_nodes(self, node_ids: List[str]) -> None:
133
+ """Remove node IDs from the node-to-tree mapping."""
134
+ for nid in node_ids:
135
+ self._node_to_tree.pop(nid, None)
136
+
137
+ def remove_tree(self, root_id: str) -> Optional[MessageTree]:
138
+ """
139
+ Remove a tree and all its node mappings from the repository.
140
+
141
+ Returns:
142
+ The removed tree, or None if not found.
143
+ """
144
+ tree = self._trees.pop(root_id, None)
145
+ if not tree:
146
+ return None
147
+ for node in tree.all_nodes():
148
+ self._node_to_tree.pop(node.node_id, None)
149
+ logger.debug("TREE_REPO: remove_tree root_id=%s", root_id)
150
+ return tree
151
+
152
  def to_dict(self) -> dict:
153
  """Serialize all trees."""
154
  return {
tests/test_handler.py CHANGED
@@ -517,3 +517,103 @@ async def test_handle_message_clear_command_deletes_message_log_ids(
517
 
518
  deleted = {c.args[1] for c in mock_platform.queue_delete_message.call_args_list}
519
  assert deleted == {"42", "43", "150"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
 
518
  deleted = {c.args[1] for c in mock_platform.queue_delete_message.call_args_list}
519
  assert deleted == {"42", "43", "150"}
520
+
521
+
522
+ @pytest.mark.asyncio
523
+ async def test_handle_message_clear_command_reply_clears_branch(
524
+ handler, mock_platform, mock_session_store, incoming_message_factory
525
+ ):
526
+ """Reply /clear to a message clears only that branch."""
527
+ root_incoming = incoming_message_factory(
528
+ text="root", chat_id="chat_1", message_id="100", reply_to_message_id=None
529
+ )
530
+ tree = await handler.tree_queue.create_tree(
531
+ node_id="100", incoming=root_incoming, status_message_id="101"
532
+ )
533
+ handler.tree_queue.register_node("101", tree.root_id)
534
+
535
+ child_incoming = incoming_message_factory(
536
+ text="child",
537
+ chat_id="chat_1",
538
+ message_id="102",
539
+ reply_to_message_id="100",
540
+ )
541
+ await handler.tree_queue.add_to_tree(
542
+ parent_node_id="100",
543
+ node_id="102",
544
+ incoming=child_incoming,
545
+ status_message_id="103",
546
+ )
547
+
548
+ deleted_ids = []
549
+
550
+ async def _capture_delete(chat_id, message_id, fire_and_forget=True):
551
+ deleted_ids.append(message_id)
552
+
553
+ mock_platform.queue_delete_message = AsyncMock(side_effect=_capture_delete)
554
+
555
+ incoming = incoming_message_factory(
556
+ text="/clear",
557
+ chat_id="chat_1",
558
+ message_id="150",
559
+ reply_to_message_id="102",
560
+ )
561
+ await handler.handle_message(incoming)
562
+
563
+ assert set(deleted_ids) == {"102", "103", "150"}
564
+ assert "100" not in deleted_ids
565
+ assert "101" not in deleted_ids
566
+ mock_session_store.remove_node_mappings.assert_called()
567
+ assert handler.tree_queue.get_tree_for_node("102") is None
568
+ assert handler.tree_queue.get_tree_for_node("100") is not None
569
+
570
+
571
+ @pytest.mark.asyncio
572
+ async def test_handle_message_clear_command_reply_unknown_sends_nothing(
573
+ handler, mock_platform, mock_session_store, incoming_message_factory
574
+ ):
575
+ """Reply /clear to unknown message sends 'Nothing to clear'."""
576
+ incoming = incoming_message_factory(
577
+ text="/clear",
578
+ chat_id="chat_1",
579
+ message_id="150",
580
+ reply_to_message_id="999",
581
+ )
582
+ await handler.handle_message(incoming)
583
+
584
+ mock_platform.queue_send_message.assert_called_once()
585
+ call_args = mock_platform.queue_send_message.call_args[0]
586
+ assert "Nothing to clear" in call_args[1]
587
+ mock_session_store.clear_all.assert_not_called()
588
+
589
+
590
+ @pytest.mark.asyncio
591
+ async def test_handle_message_clear_command_reply_to_root_clears_tree(
592
+ handler, mock_platform, mock_session_store, incoming_message_factory
593
+ ):
594
+ """Reply /clear to root message clears entire tree."""
595
+ root_incoming = incoming_message_factory(
596
+ text="root", chat_id="chat_1", message_id="100", reply_to_message_id=None
597
+ )
598
+ await handler.tree_queue.create_tree(
599
+ node_id="100", incoming=root_incoming, status_message_id="101"
600
+ )
601
+
602
+ deleted_ids = []
603
+
604
+ async def _capture_delete(chat_id, message_id, fire_and_forget=True):
605
+ deleted_ids.append(message_id)
606
+
607
+ mock_platform.queue_delete_message = AsyncMock(side_effect=_capture_delete)
608
+
609
+ incoming = incoming_message_factory(
610
+ text="/clear",
611
+ chat_id="chat_1",
612
+ message_id="150",
613
+ reply_to_message_id="100",
614
+ )
615
+ await handler.handle_message(incoming)
616
+
617
+ assert set(deleted_ids) == {"100", "101", "150"}
618
+ mock_session_store.remove_tree.assert_called_once_with("100")
619
+ assert handler.tree_queue.get_tree_count() == 0
tests/test_tree_queue.py CHANGED
@@ -282,6 +282,83 @@ class TestMessageTree:
282
  assert node is not None
283
  assert node.session_id == "sess_1"
284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
 
286
  class TestTreeQueueManager:
287
  """Test TreeQueueManager class."""
@@ -441,6 +518,97 @@ class TestTreeQueueManager:
441
 
442
  processing_complete.set()
443
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
 
445
  class TestSessionStoreTrees:
446
  """Test SessionStore tree methods."""
 
282
  assert node is not None
283
  assert node.session_id == "sess_1"
284
 
285
+ @pytest.mark.asyncio
286
+ async def test_get_descendants(self):
287
+ """Test get_descendants returns node and all descendants."""
288
+ root_incoming = IncomingMessage(
289
+ text="Root", chat_id="1", user_id="1", message_id="root", platform="test"
290
+ )
291
+ root = MessageNode(
292
+ node_id="root", incoming=root_incoming, status_message_id="s1"
293
+ )
294
+ tree = MessageTree(root)
295
+
296
+ child_incoming = IncomingMessage(
297
+ text="Child",
298
+ chat_id="1",
299
+ user_id="1",
300
+ message_id="child",
301
+ platform="test",
302
+ reply_to_message_id="root",
303
+ )
304
+ await tree.add_node("child", child_incoming, "s2", "root")
305
+
306
+ grandchild_incoming = IncomingMessage(
307
+ text="Grand",
308
+ chat_id="1",
309
+ user_id="1",
310
+ message_id="grand",
311
+ platform="test",
312
+ reply_to_message_id="child",
313
+ )
314
+ await tree.add_node("grand", grandchild_incoming, "s3", "child")
315
+
316
+ assert tree.get_descendants("root") == ["root", "child", "grand"]
317
+ assert tree.get_descendants("child") == ["child", "grand"]
318
+ assert tree.get_descendants("grand") == ["grand"]
319
+ assert tree.get_descendants("nonexistent") == []
320
+
321
+ @pytest.mark.asyncio
322
+ async def test_remove_branch(self):
323
+ """Test remove_branch removes subtree and updates parent."""
324
+ root_incoming = IncomingMessage(
325
+ text="Root", chat_id="1", user_id="1", message_id="root", platform="test"
326
+ )
327
+ root = MessageNode(
328
+ node_id="root", incoming=root_incoming, status_message_id="s1"
329
+ )
330
+ tree = MessageTree(root)
331
+
332
+ child_incoming = IncomingMessage(
333
+ text="Child",
334
+ chat_id="1",
335
+ user_id="1",
336
+ message_id="child",
337
+ platform="test",
338
+ reply_to_message_id="root",
339
+ )
340
+ await tree.add_node("child", child_incoming, "s2", "root")
341
+
342
+ grandchild_incoming = IncomingMessage(
343
+ text="Grand",
344
+ chat_id="1",
345
+ user_id="1",
346
+ message_id="grand",
347
+ platform="test",
348
+ reply_to_message_id="child",
349
+ )
350
+ await tree.add_node("grand", grandchild_incoming, "s3", "child")
351
+
352
+ async with tree.with_lock():
353
+ removed = tree.remove_branch("child")
354
+
355
+ assert len(removed) == 2
356
+ assert {n.node_id for n in removed} == {"child", "grand"}
357
+ assert tree.get_node("child") is None
358
+ assert tree.get_node("grand") is None
359
+ assert tree.get_node("root") is not None
360
+ assert "child" not in tree.get_root().children_ids
361
+
362
 
363
  class TestTreeQueueManager:
364
  """Test TreeQueueManager class."""
 
518
 
519
  processing_complete.set()
520
 
521
+ @pytest.mark.asyncio
522
+ async def test_cancel_branch(self):
523
+ """Test cancel_branch cancels only nodes in subtree."""
524
+ manager = TreeQueueManager()
525
+
526
+ root_incoming = IncomingMessage(
527
+ text="Root", chat_id="1", user_id="1", message_id="root", platform="test"
528
+ )
529
+ await manager.create_tree("root", root_incoming, "s1")
530
+
531
+ child_incoming = IncomingMessage(
532
+ text="Child",
533
+ chat_id="1",
534
+ user_id="1",
535
+ message_id="child",
536
+ platform="test",
537
+ reply_to_message_id="root",
538
+ )
539
+ tree, _ = await manager.add_to_tree("root", "child", child_incoming, "s2")
540
+
541
+ sibling_incoming = IncomingMessage(
542
+ text="Sibling",
543
+ chat_id="1",
544
+ user_id="1",
545
+ message_id="sibling",
546
+ platform="test",
547
+ reply_to_message_id="root",
548
+ )
549
+ await manager.add_to_tree("root", "sibling", sibling_incoming, "s3")
550
+
551
+ cancelled = await manager.cancel_branch("child")
552
+ assert len(cancelled) == 1
553
+ assert cancelled[0].node_id == "child"
554
+
555
+ child_node = tree.get_node("child")
556
+ assert child_node is not None
557
+ assert child_node.state == MessageState.ERROR
558
+
559
+ sibling_node = tree.get_node("sibling")
560
+ assert sibling_node is not None
561
+ assert sibling_node.state == MessageState.PENDING
562
+
563
+ @pytest.mark.asyncio
564
+ async def test_remove_branch_non_root(self):
565
+ """Test remove_branch removes only the subtree when branch is not root."""
566
+ manager = TreeQueueManager()
567
+
568
+ root_incoming = IncomingMessage(
569
+ text="Root", chat_id="1", user_id="1", message_id="root", platform="test"
570
+ )
571
+ await manager.create_tree("root", root_incoming, "s1")
572
+
573
+ child_incoming = IncomingMessage(
574
+ text="Child",
575
+ chat_id="1",
576
+ user_id="1",
577
+ message_id="child",
578
+ platform="test",
579
+ reply_to_message_id="root",
580
+ )
581
+ tree, _ = await manager.add_to_tree("root", "child", child_incoming, "s2")
582
+
583
+ removed, root_id, removed_entire = await manager.remove_branch("child")
584
+
585
+ assert len(removed) == 1
586
+ assert removed[0].node_id == "child"
587
+ assert root_id == "root"
588
+ assert removed_entire is False
589
+ assert manager.get_tree_for_node("child") is None
590
+ assert manager.get_tree("root") is not None
591
+ assert tree.get_node("child") is None
592
+ assert "child" not in tree.get_root().children_ids
593
+
594
+ @pytest.mark.asyncio
595
+ async def test_remove_branch_root_removes_tree(self):
596
+ """Test remove_branch when branch is root removes entire tree."""
597
+ manager = TreeQueueManager()
598
+
599
+ root_incoming = IncomingMessage(
600
+ text="Root", chat_id="1", user_id="1", message_id="root", platform="test"
601
+ )
602
+ await manager.create_tree("root", root_incoming, "s1")
603
+
604
+ removed, root_id, removed_entire = await manager.remove_branch("root")
605
+
606
+ assert len(removed) == 1
607
+ assert root_id == "root"
608
+ assert removed_entire is True
609
+ assert manager.get_tree("root") is None
610
+ assert manager.get_tree_for_node("root") is None
611
+
612
 
613
  class TestSessionStoreTrees:
614
  """Test SessionStore tree methods."""