@@ -442,6 +442,168 @@ async def test_branching_functionality(agent: Agent):
442442 session .close ()
443443
444444
445+ async def test_delete_branch_removes_branch_only_messages ():
446+ """Deleting a branch should not leave unreferenced branch-only messages behind."""
447+ session_id = "branch_delete_cleanup_test"
448+ session = AdvancedSQLiteSession (session_id = session_id , create_tables = True )
449+
450+ main_items : list [TResponseInputItem ] = [
451+ {"role" : "user" , "content" : "First question" },
452+ {"role" : "assistant" , "content" : "First answer" },
453+ {"role" : "user" , "content" : "Second question" },
454+ {"role" : "assistant" , "content" : "Second answer" },
455+ ]
456+ await session .add_items (main_items )
457+
458+ await session .create_branch_from_turn (2 , "cleanup_branch" )
459+ branch_items : list [TResponseInputItem ] = [
460+ {"role" : "user" , "content" : "Branch-only question" },
461+ {"role" : "assistant" , "content" : "Branch-only answer" },
462+ ]
463+ await session .add_items (branch_items )
464+
465+ await session .delete_branch ("cleanup_branch" , force = True )
466+
467+ with session ._locked_connection () as conn :
468+ rows = conn .execute (
469+ f"""
470+ SELECT message_data
471+ FROM { session .messages_table }
472+ WHERE session_id = ?
473+ ORDER BY id
474+ """ ,
475+ (session .session_id ,),
476+ ).fetchall ()
477+
478+ contents = [json .loads (message_data )["content" ] for (message_data ,) in rows ]
479+ assert contents == [
480+ "First question" ,
481+ "First answer" ,
482+ "Second question" ,
483+ "Second answer" ,
484+ ]
485+ assert await session .get_items (branch_id = "main" ) == main_items
486+
487+ session .close ()
488+
489+
490+ async def test_delete_branch_keeps_messages_still_referenced_by_another_branch ():
491+ """Deleting one branch should keep messages inherited by a surviving branch."""
492+ session = AdvancedSQLiteSession (
493+ session_id = "branch_delete_shared_descendant_test" ,
494+ create_tables = True ,
495+ )
496+
497+ main_items : list [TResponseInputItem ] = [
498+ {"role" : "user" , "content" : "Main first question" },
499+ {"role" : "assistant" , "content" : "Main first answer" },
500+ {"role" : "user" , "content" : "Main second question" },
501+ {"role" : "assistant" , "content" : "Main second answer" },
502+ ]
503+ branch_a_shared_items : list [TResponseInputItem ] = [
504+ {"role" : "user" , "content" : "Branch A shared question" },
505+ {"role" : "assistant" , "content" : "Branch A shared answer" },
506+ ]
507+ branch_a_only_items : list [TResponseInputItem ] = [
508+ {"role" : "user" , "content" : "Branch A only question" },
509+ {"role" : "assistant" , "content" : "Branch A only answer" },
510+ ]
511+
512+ try :
513+ await session .add_items (main_items )
514+ await session .create_branch_from_turn (2 , "branch_a" )
515+ await session .add_items (branch_a_shared_items + branch_a_only_items )
516+
517+ await session .create_branch_from_turn (3 , "branch_b" )
518+ await session .delete_branch ("branch_a" )
519+
520+ with session ._locked_connection () as conn :
521+ rows = conn .execute (
522+ f"""
523+ SELECT message_data
524+ FROM { session .messages_table }
525+ WHERE session_id = ?
526+ ORDER BY id
527+ """ ,
528+ (session .session_id ,),
529+ ).fetchall ()
530+
531+ contents = [json .loads (message_data )["content" ] for (message_data ,) in rows ]
532+ assert "Branch A shared question" in contents
533+ assert "Branch A shared answer" in contents
534+ assert "Branch A only question" not in contents
535+ assert "Branch A only answer" not in contents
536+ assert await session .get_items (branch_id = "branch_b" ) == [
537+ * main_items [:2 ],
538+ * branch_a_shared_items ,
539+ ]
540+ finally :
541+ session .close ()
542+
543+
544+ async def test_orphan_cleanup_uses_set_based_delete_for_many_messages ():
545+ """Orphan cleanup should not build one DELETE parameter per orphaned row."""
546+
547+ class RecordingCursor :
548+ def __init__ (self , cursor : Any , connection : "RecordingConnection" ) -> None :
549+ self ._cursor = cursor
550+ self ._connection = connection
551+
552+ @property
553+ def rowcount (self ) -> int :
554+ return cast (int , self ._cursor .rowcount )
555+
556+ def execute (self , sql : str , parameters : Any = None ) -> Any :
557+ normalized_sql = " " .join (sql .split ()).upper ()
558+ if normalized_sql .startswith ("DELETE" ):
559+ self ._connection .delete_parameter_counts .append (len (parameters or ()))
560+ if parameters is None :
561+ return self ._cursor .execute (sql )
562+ return self ._cursor .execute (sql , parameters )
563+
564+ def fetchall (self ) -> Any :
565+ return self ._cursor .fetchall ()
566+
567+ def close (self ) -> None :
568+ self ._cursor .close ()
569+
570+ class RecordingConnection :
571+ def __init__ (self , conn : Any ) -> None :
572+ self ._conn = conn
573+ self .delete_parameter_counts : list [int ] = []
574+
575+ def cursor (self ) -> RecordingCursor :
576+ return RecordingCursor (self ._conn .cursor (), self )
577+
578+ session = AdvancedSQLiteSession (
579+ session_id = "branch_delete_many_orphans_cleanup" ,
580+ create_tables = True ,
581+ )
582+ orphan_items : list [TResponseInputItem ] = [
583+ {"role" : "user" , "content" : f"orphan { i } " } for i in range (1200 )
584+ ]
585+
586+ try :
587+ with session ._locked_connection () as conn :
588+ session ._insert_items (conn , orphan_items )
589+ conn .commit ()
590+
591+ recording_conn = RecordingConnection (conn )
592+ deleted_count = session ._cleanup_orphaned_messages_sync (cast (Any , recording_conn ))
593+ conn .commit ()
594+
595+ remaining_count = conn .execute (
596+ f"SELECT COUNT(*) FROM { session .messages_table } WHERE session_id = ?" ,
597+ (session .session_id ,),
598+ ).fetchone ()[0 ]
599+
600+ assert deleted_count == len (orphan_items )
601+ assert remaining_count == 0
602+ assert recording_conn .delete_parameter_counts == [2 ]
603+ finally :
604+ session .close ()
605+
606+
445607async def test_get_conversation_turns ():
446608 """Test get_conversation_turns functionality."""
447609 session_id = "conversation_turns_test"
0 commit comments