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