@@ -581,6 +581,9 @@ def test_handle_thinking_delta_new_part_with_vendor_id():
581581 parts = manager .get_parts ()
582582 assert parts == snapshot ([ThinkingPart (content = 'new thought' )])
583583
584+ # Verify vendor_part_id was mapped to the part index
585+ assert manager .is_vendor_id_mapped ('thinking' )
586+
584587
585588def test_handle_thinking_delta_no_content ():
586589 manager = ModelResponsePartsManager ()
@@ -603,6 +606,98 @@ def test_handle_thinking_delta_no_content_or_signature():
603606 pass
604607
605608
609+ def test_handle_text_delta_append_to_thinking_part_without_vendor_id ():
610+ """Test appending to ThinkingPart when vendor_part_id is None (lines 202-203)."""
611+ manager = ModelResponsePartsManager ()
612+ thinking_tags = ('<think>' , '</think>' )
613+
614+ # Create a ThinkingPart using handle_text_delta with thinking tags and vendor_part_id=None
615+ events = list (manager .handle_text_delta (vendor_part_id = None , content = '<think>initial' , thinking_tags = thinking_tags ))
616+ assert len (events ) == 1
617+ assert isinstance (events [0 ], PartStartEvent )
618+ assert isinstance (events [0 ].part , ThinkingPart )
619+ assert events [0 ].part .content == 'initial'
620+
621+ # Now append more content with vendor_part_id=None - should append to existing ThinkingPart
622+ events = list (manager .handle_text_delta (vendor_part_id = None , content = ' reasoning' , thinking_tags = thinking_tags ))
623+ assert len (events ) == 1
624+ assert isinstance (events [0 ], PartDeltaEvent )
625+ assert events [0 ].index == 0
626+
627+ parts = manager .get_parts ()
628+ assert len (parts ) == 1
629+ assert isinstance (parts [0 ], ThinkingPart )
630+ assert parts [0 ].content == 'initial reasoning'
631+
632+
633+ def test_simple_path_whitespace_handling ():
634+ """Test whitespace-only prefix with ignore_leading_whitespace in simple path (S10 → S11).
635+
636+ This tests the branch where whitespace before a start tag is ignored when
637+ vendor_part_id=None (which routes to simple path).
638+ """
639+ manager = ModelResponsePartsManager ()
640+ thinking_tags = ('<think>' , '</think>' )
641+
642+ events = list (
643+ manager .handle_text_delta (
644+ vendor_part_id = None ,
645+ content = ' \n <think>reasoning' ,
646+ thinking_tags = thinking_tags ,
647+ ignore_leading_whitespace = True ,
648+ )
649+ )
650+
651+ assert len (events ) == 1
652+ assert isinstance (events [0 ], PartStartEvent )
653+ assert isinstance (events [0 ].part , ThinkingPart )
654+ assert events [0 ].part .content == 'reasoning'
655+
656+ parts = manager .get_parts ()
657+ assert len (parts ) == 1
658+ assert isinstance (parts [0 ], ThinkingPart )
659+ assert parts [0 ].content == 'reasoning'
660+
661+
662+ def test_simple_path_text_prefix_rejection ():
663+ """Test that text before start tag disables thinking tag detection in simple path (S12).
664+
665+ When there's non-whitespace text before the start tag, the entire content should be
666+ treated as a TextPart with the tag included as literal text.
667+ """
668+ manager = ModelResponsePartsManager ()
669+ thinking_tags = ('<think>' , '</think>' )
670+
671+ events = list (
672+ manager .handle_text_delta (vendor_part_id = None , content = 'foo<think>reasoning' , thinking_tags = thinking_tags )
673+ )
674+
675+ assert len (events ) == 1
676+ assert isinstance (events [0 ], PartStartEvent )
677+ assert isinstance (events [0 ].part , TextPart )
678+ assert events [0 ].part .content == 'foo<think>reasoning'
679+
680+ parts = manager .get_parts ()
681+ assert len (parts ) == 1
682+ assert isinstance (parts [0 ], TextPart )
683+ assert parts [0 ].content == 'foo<think>reasoning'
684+
685+
686+ def test_empty_whitespace_content_with_ignore_leading_whitespace ():
687+ """Test that empty/whitespace content is ignored when ignore_leading_whitespace=True (line 282)."""
688+ manager = ModelResponsePartsManager ()
689+
690+ # Empty content with ignore_leading_whitespace should yield no events
691+ events = list (manager .handle_text_delta (vendor_part_id = 'id1' , content = '' , ignore_leading_whitespace = True ))
692+ assert len (events ) == 0
693+ assert manager .get_parts () == []
694+
695+ # Whitespace-only content with ignore_leading_whitespace should yield no events
696+ events = list (manager .handle_text_delta (vendor_part_id = 'id2' , content = ' \n \t ' , ignore_leading_whitespace = True ))
697+ assert len (events ) == 0
698+ assert manager .get_parts () == []
699+
700+
606701def test_handle_part ():
607702 manager = ModelResponsePartsManager ()
608703
@@ -632,3 +727,60 @@ def test_handle_part():
632727 event = manager .handle_part (vendor_part_id = None , part = part3 )
633728 assert event == snapshot (PartStartEvent (index = 1 , part = part3 ))
634729 assert manager .get_parts () == snapshot ([part2 , part3 ])
730+
731+
732+ def test_handle_tool_call_delta_no_vendor_id_with_non_tool_latest_part ():
733+ """Test handle_tool_call_delta with vendor_part_id=None when latest part is NOT a tool call (line 515->526)."""
734+ manager = ModelResponsePartsManager ()
735+
736+ # Create a TextPart first
737+ for _ in manager .handle_text_delta (vendor_part_id = None , content = 'some text' ):
738+ pass
739+
740+ # Try to send a tool call delta with vendor_part_id=None and tool_name=None
741+ # Since latest part is NOT a tool call, this should create a new incomplete tool call delta
742+ event = manager .handle_tool_call_delta (vendor_part_id = None , tool_name = None , args = '{"arg":' )
743+
744+ # Since tool_name is None for a new part, we get a ToolCallPartDelta with no event
745+ assert event is None
746+
747+ # The ToolCallPartDelta is created internally but not returned by get_parts() since it's incomplete
748+ assert manager .has_incomplete_parts ()
749+ assert len (manager .get_parts ()) == 1
750+ assert isinstance (manager .get_parts ()[0 ], TextPart )
751+
752+
753+ def test_handle_thinking_delta_raises_error_when_thinking_after_text ():
754+ """Test that handle_thinking_delta raises error when trying to create ThinkingPart after TextPart."""
755+ manager = ModelResponsePartsManager ()
756+
757+ # Create a TextPart first
758+ for _ in manager .handle_text_delta (vendor_part_id = None , content = 'some text' ):
759+ pass
760+
761+ # Now try to create a ThinkingPart with vendor_part_id=None
762+ # This should raise an error because thinking must come before text
763+ with pytest .raises (
764+ UnexpectedModelBehavior , match = 'Cannot create ThinkingPart after TextPart: thinking must come before text'
765+ ):
766+ for _ in manager .handle_thinking_delta (vendor_part_id = None , content = 'thinking' ):
767+ pass
768+
769+
770+ def test_handle_thinking_delta_create_new_part_with_no_vendor_id ():
771+ """Test creating new ThinkingPart when vendor_part_id is None and no parts exist yet."""
772+ manager = ModelResponsePartsManager ()
773+
774+ # Create ThinkingPart with vendor_part_id=None (no parts exist yet, so no constraint violation)
775+ events = list (manager .handle_thinking_delta (vendor_part_id = None , content = 'thinking' ))
776+
777+ assert len (events ) == 1
778+ assert isinstance (events [0 ], PartStartEvent )
779+ assert events [0 ].index == 0
780+
781+ parts = manager .get_parts ()
782+ assert len (parts ) == 1
783+ assert parts [0 ] == snapshot (ThinkingPart (content = 'thinking' ))
784+
785+ # Verify vendor_part_id was NOT mapped (it's None)
786+ assert not manager .is_vendor_id_mapped ('thinking' )
0 commit comments