@@ -72,7 +72,7 @@ def suffix_prefix_overlap(s1: str, s2: str) -> int:
7272class PartialThinkingTag (BaseModel , validate_assignment = True ):
7373 respective_tag : str
7474 buffer : str = ''
75- previous_part_index : int | None = None
75+ previous_part_index : int
7676 vendor_part_id : VendorId | None = None
7777
7878 @model_validator (mode = 'after' )
@@ -89,6 +89,10 @@ def expected_next(self) -> str:
8989 def is_complete (self ) -> bool :
9090 return self .buffer == self .respective_tag
9191
92+ @property
93+ def has_previous_part (self ) -> bool :
94+ return self .previous_part_index >= 0
95+
9296
9397@dataclass
9498class StartTagValidation :
@@ -168,7 +172,7 @@ def flush(self) -> str:
168172 return self .respective_opening_tag + self .buffer
169173
170174 def validate_new_content (self , new_content : str , trim_whitespace : bool = False ) -> EndTagValidation :
171- if trim_whitespace and self .previous_part_index is None :
175+ if trim_whitespace and not self .has_previous_part : # pragma: no cover
172176 new_content = new_content .lstrip ()
173177
174178 if not new_content :
@@ -183,7 +187,7 @@ def validate_new_content(self, new_content: str, trim_whitespace: bool = False)
183187 content_before_closed = content_before_closed , content_after_closed = content_after_closed
184188 )
185189
186- if new_content .startswith (self .expected_next ):
190+ if new_content .startswith (self .expected_next ): # pragma: no cover
187191 tag_content = combined [: len (self .respective_tag )]
188192 self .buffer = tag_content
189193 content_after_closed = combined [len (self .respective_tag ) :]
@@ -214,7 +218,7 @@ class ModelResponsePartsManager:
214218 """Tracks the vendor part IDs of parts to their indices in the `_parts` list.
215219
216220 Not all parts arrive with vendor part IDs, so the length of the tracker doesn't mirror the length of the _parts.
217- `ThinkingPart`s that are created via the `handle_text_delta` will stop being tracked once their closing tag is seen.
221+ `ThinkingPart`s that are created via embedded thinking will stop being tracked once their closing tag is seen.
218222 """
219223
220224 _partial_tags_list : list [PartialStartTag | PartialEndTag ] = field (default_factory = list , init = False )
@@ -262,15 +266,9 @@ def _get_partial_by_part_index(self, part_index: int) -> PartialStartTag | Parti
262266 return None
263267
264268 def _stop_tracking_partial_tag (self , partial_tag : PartialStartTag | PartialEndTag ) -> None :
265- """Stop tracking a partial tag.
266-
267- Removes the partial tag from the tracking list.
268-
269- Args:
270- partial_tag: The partial tag to stop tracking.
271- part_index: The part index where the tag is tracked (unused, kept for API compatibility).
272- """
273- if partial_tag in self ._partial_tags_list :
269+ """Stop tracking a partial tag."""
270+ if partial_tag in self ._partial_tags_list : # pragma: no cover
271+ # this is a defensive check in case we try to remove a tag that wasn't tracked
274272 self ._partial_tags_list .remove (partial_tag )
275273
276274 def _get_active_partial_tag (
@@ -280,7 +278,7 @@ def _get_active_partial_tag(
280278 ) -> PartialStartTag | PartialEndTag | None :
281279 """Get the active partial tag.
282280
283- - if vendor_part_id provided: lookup by vendor_id first (most direct )
281+ - if vendor_part_id provided: lookup by vendor_id first (most relevant )
284282 - if existing_part exists: lookup by that part's index
285283 - if no existing_part: lookup by latest part's index, or index -1 for unattached tags
286284 """
@@ -533,11 +531,7 @@ def _handle_delayed_thinking(
533531 yield PartStartEvent (index = new_part_index , part = new_thinking_part )
534532
535533 if partial_end_tag .is_complete :
536- # Remove tracking if still present
537- if end_tag_validation .content_before_closed :
538- new_part_index = partial_end_tag .previous_part_index
539- if new_part_index is not None :
540- self ._stop_tracking_partial_tag (partial_end_tag )
534+ self ._stop_tracking_partial_tag (partial_end_tag )
541535
542536 if end_tag_validation .content_after_closed :
543537 yield self ._emit_text_start (
@@ -560,7 +554,6 @@ def _handle_thinking_opening(
560554 """Handle opening tag validation and buffering."""
561555 text_part = cast (_ExistingPart [TextPart ] | None , text_part )
562556
563- # Create partial tag if needed
564557 if partial_start_tag is None :
565558 partial_start_tag = PartialStartTag (
566559 respective_tag = opening_tag ,
@@ -570,7 +563,6 @@ def _handle_thinking_opening(
570563 )
571564 self ._partial_tags_list .append (partial_start_tag )
572565
573- # Validate content
574566 start_tag_validation = partial_start_tag .validate_new_content (content )
575567
576568 # Emit flushed buffer as text
@@ -622,13 +614,11 @@ def _create_partial_end_tag(
622614 vendor_part_id = vendor_part_id ,
623615 )
624616
625- # Process thinking content against closing tag
626617 end_tag_validation = partial_end_tag .validate_new_content (
627618 thinking_content , trim_whitespace = ignore_leading_whitespace
628619 )
629620
630621 if end_tag_validation .content_before_closed :
631- # Create ThinkingPart
632622 new_thinking_part = ThinkingPart (content = end_tag_validation .content_before_closed )
633623 new_part_index = self ._append_and_track_new_part (new_thinking_part , vendor_part_id )
634624 partial_end_tag .previous_part_index = new_part_index
@@ -700,28 +690,26 @@ def remove_partial_and_emit_buffered(
700690
701691 # Flush remaining partial tags
702692 for partial_tag in list (self ._partial_tags_list ):
703- has_content = partial_tag .flush () if isinstance (partial_tag , PartialEndTag ) else partial_tag .buffer
704- if not has_content :
693+ buffered_content = partial_tag .flush () if isinstance (partial_tag , PartialEndTag ) else partial_tag .buffer
694+ if not buffered_content :
705695 self ._stop_tracking_partial_tag (partial_tag ) # partial tag has an associated part index of -1 here
706696 continue
707697
708- # Check >= 0 to exclude the -1 sentinel (unattached tag) from part lookup
709- if partial_tag .previous_part_index is not None and partial_tag .previous_part_index >= 0 :
698+ if not partial_tag .has_previous_part :
699+ # No associated part - create new TextPart
700+ self ._stop_tracking_partial_tag (partial_tag ) # partial tag has an associated part index of -1 here
701+
702+ new_text_part = TextPart (content = '' )
703+ new_part_index = self ._append_and_track_new_part (new_text_part , vendor_part_id = None )
704+ yield from remove_partial_and_emit_buffered (partial_tag , new_part_index , new_text_part )
705+ else :
706+ # exclude the -1 sentinel (unattached tag) from part lookup
710707 part_index = partial_tag .previous_part_index
711708 part = self ._parts [part_index ]
712709 if isinstance (part , TextPart | ThinkingPart ):
713710 yield from remove_partial_and_emit_buffered (partial_tag , part_index , part )
714711 else : # pragma: no cover
715712 raise RuntimeError ('Partial tag is associated with a non-text/non-thinking part' )
716- else :
717- # No associated part - create new TextPart
718- buffered_content = partial_tag .flush () if isinstance (partial_tag , PartialEndTag ) else partial_tag .buffer
719- self ._stop_tracking_partial_tag (partial_tag ) # partial tag has an associated part index of -1 here
720-
721- if buffered_content :
722- new_text_part = TextPart (content = buffered_content )
723- new_part_index = self ._append_and_track_new_part (new_text_part , vendor_part_id = None )
724- yield PartStartEvent (index = new_part_index , part = new_text_part )
725713
726714 def handle_thinking_delta (
727715 self ,
0 commit comments