@@ -283,6 +283,72 @@ def flush_pending():
283283 return messages
284284
285285
286+ def filter_output_by_content (output : list , content : str ) -> list :
287+ """
288+ Drop output items whose <details> block was removed from content.
289+
290+ Matches by id attribute. Items with no ID are kept for backward
291+ compatibility with content serialized before id= was added.
292+ Items whose type is entirely absent from content are dropped wholesale.
293+ Legacy: if a type is present with no id= attrs, keep the first N items
294+ of that type where N = the number of blocks of that type remaining in
295+ content, so individually deleted blocks are respected even without IDs.
296+ """
297+ if not isinstance (output , list ):
298+ return []
299+ if not isinstance (content , str ):
300+ content = ''
301+
302+ present_ids = set (re .findall (r'<details\b[^>]*\bid="([^"]+)"' , content ))
303+ present_types = set (re .findall (r'<details\b[^>]*\btype="([^"]+)"' , content ))
304+
305+ # Map output item type → <details> type attribute value
306+ DETAILS_TYPE = {
307+ 'function_call' : 'tool_calls' ,
308+ 'function_call_output' : 'tool_calls' ,
309+ 'reasoning' : 'reasoning' ,
310+ 'open_webui:code_interpreter' : 'code_interpreter' ,
311+ }
312+
313+ # No <details> blocks: pass through if no structured item has an ID (pre-serialization legacy)
314+ if not present_types :
315+ if not any (item .get ('call_id' ) or item .get ('id' )
316+ for item in output if DETAILS_TYPE .get (item .get ('type' , '' ))):
317+ return list (output )
318+
319+ # types with ≥1 id= in content; others treated as legacy (pre-id=)
320+ types_with_ids = set (re .findall (r'<details\b[^>]*\btype="([^"]+)"[^>]*\bid="[^"]*"' , content ))
321+ types_with_ids |= set (re .findall (r'<details\b[^>]*\bid="[^"]*"[^>]*\btype="([^"]+)"' , content ))
322+
323+ # Legacy (no id=): count remaining blocks per type for positional matching
324+ type_block_counts = {}
325+ for t in re .findall (r'<details\b[^>]*\btype="([^"]+)"' , content ):
326+ type_block_counts [t ] = type_block_counts .get (t , 0 ) + 1
327+
328+ filtered = []
329+ seen_legacy = {} # kept count per legacy type
330+ for item in output :
331+ details_type = DETAILS_TYPE .get (item .get ('type' , '' ))
332+ if details_type is None :
333+ filtered .append (item ) # non-visual item (e.g. 'message'): always keep
334+ continue
335+ if details_type not in present_types :
336+ continue # entire type removed from content: drop
337+ if details_type not in types_with_ids :
338+ # Legacy (no id=): keep first N items, N = block count in content
339+ n = type_block_counts .get (details_type , 0 )
340+ if seen_legacy .get (details_type , 0 ) < n :
341+ filtered .append (item )
342+ seen_legacy [details_type ] = seen_legacy .get (details_type , 0 ) + 1
343+ continue
344+ item_id = item .get ('call_id' ) or item .get ('id' , '' )
345+ if not item_id or item_id in present_ids :
346+ filtered .append (item ) # no ID (old format) or ID still present: keep
347+ # else: ID not found in content → user deleted this block: drop
348+
349+ return filtered
350+
351+
286352def get_last_user_message (messages : list [dict ]) -> Optional [str ]:
287353 message = get_last_user_message_item (messages )
288354 if message is None :
0 commit comments