From d16001be7a3c9e54929de5b645a19b710ec04c2f Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 12 May 2026 16:36:01 +1000 Subject: [PATCH 1/2] fix(gateway): preserve spaced MEDIA paths --- gateway/platforms/base.py | 16 +++++++++++---- gateway/run.py | 20 ++---------------- gateway/stream_consumer.py | 11 +++++++++- mcp_serve.py | 16 +++++++++++++-- tests/gateway/test_platform_base.py | 29 ++++++++++++++++++++++++++- tests/gateway/test_stream_consumer.py | 19 +++++++++++++++++- tests/test_mcp_serve.py | 19 ++++++++++++++++++ ui-tui/src/__tests__/markdown.test.ts | 6 ++++++ ui-tui/src/components/markdown.tsx | 2 +- 9 files changed, 110 insertions(+), 28 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 5899843aa1ee..26a496b4c4d0 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1275,6 +1275,16 @@ class BasePlatformAdapter(ABC): - Sending messages/responses - Handling media """ + _MEDIA_EXTENSIONS_RE = ( + r"png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|" + r"epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa|kmz|kml|" + r"json|xml|html?|geojson|gpx" + ) + _MEDIA_TAG_RE = re.compile( + r'''[`"']?MEDIA:\s*(?P`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|[^\n]+?\.(?:''' + + _MEDIA_EXTENSIONS_RE + + r''')(?=[\s`"',;:)\]}]|$)|\S+)[`"']?''' + ) def __init__(self, config: PlatformConfig, platform: Platform): self.config = config @@ -2136,9 +2146,7 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]: # Extract MEDIA: tags, allowing optional whitespace after the colon # and quoted/backticked paths for LLM-formatted outputs. - media_pattern = re.compile( - r'''[`"']?MEDIA:\s*(?P`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$))[`"']?''' - ) + media_pattern = BasePlatformAdapter._MEDIA_TAG_RE for match in media_pattern.finditer(content): path = match.group("path").strip() if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'": @@ -3150,7 +3158,7 @@ async def _stop_typing_task() -> None: # Strip any remaining internal directives from message body (fixes #1561) text_content = text_content.replace("[[audio_as_voice]]", "").strip() text_content = text_content.replace("[[as_document]]", "").strip() - text_content = re.sub(r"MEDIA:\s*\S+", "", text_content).strip() + text_content = BasePlatformAdapter._MEDIA_TAG_RE.sub("", text_content).strip() if images: logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response)) diff --git a/gateway/run.py b/gateway/run.py index 9512060d7cb1..1ce9a75ab6ae 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -15778,15 +15778,7 @@ def _clarify_callback_sync(question: str, choices) -> str: if _hm.get("role") in {"tool", "function"}: _hc = _hm.get("content", "") if "MEDIA:" in _hc: - _TOOL_MEDIA_RE = re.compile( - r'MEDIA:((?:/|~\/)\S+\.(?:png|jpe?g|gif|webp|' - r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|' - r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|' - r'txt|csv|apk|ipa))', - re.IGNORECASE - ) - for _match in _TOOL_MEDIA_RE.finditer(_hc): - _p = _match.group(1).strip().rstrip('",}') + for _p, _ in BasePlatformAdapter.extract_media(_hc)[0]: if _p: _history_media_paths.add(_p) @@ -16074,15 +16066,7 @@ def _approval_notify_sync(approval_data: dict) -> None: if msg.get("role") in {"tool", "function"}: content = msg.get("content", "") if "MEDIA:" in content: - _TOOL_MEDIA_RE = re.compile( - r'MEDIA:((?:/|~\/)\S+\.(?:png|jpe?g|gif|webp|' - r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|' - r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|' - r'txt|csv|apk|ipa))', - re.IGNORECASE - ) - for match in _TOOL_MEDIA_RE.finditer(content): - path = match.group(1).strip().rstrip('",}') + for path, _ in BasePlatformAdapter.extract_media(content)[0]: if path and path not in _history_media_paths: media_tags.append(f"MEDIA:{path}") if "[[audio_as_voice]]" in content: diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 3c761d528ab2..bee4c04ee000 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -612,7 +612,16 @@ async def run(self) -> None: # Pattern to strip MEDIA: tags (including optional surrounding quotes). # Matches the simple cleanup regex used by the non-streaming path in # gateway/platforms/base.py for post-processing. - _MEDIA_RE = re.compile(r'''[`"']?MEDIA:\s*\S+[`"']?''') + _MEDIA_EXTENSIONS_RE = ( + r"png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|" + r"epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa|kmz|kml|" + r"json|xml|html?|geojson|gpx" + ) + _MEDIA_RE = re.compile( + r'''[`"']?MEDIA:\s*(?:`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|[^\n]+?\.(?:''' + + _MEDIA_EXTENSIONS_RE + + r''')(?=[\s`"',;:)\]}]|$)|\S+)[`"']?''' + ) @staticmethod def _clean_for_display(text: str) -> str: diff --git a/mcp_serve.py b/mcp_serve.py index 5ae0261d9af7..1290006f819d 100644 --- a/mcp_serve.py +++ b/mcp_serve.py @@ -176,9 +176,21 @@ def _extract_attachments(msg: dict) -> List[dict]: # MEDIA: tags in text content text = _extract_message_content(msg) if text: - media_pattern = re.compile(r'MEDIA:\s*(\S+)') + media_extensions = ( + r"png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|" + r"epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa|kmz|kml|" + r"json|xml|html?|geojson|gpx" + ) + media_pattern = re.compile( + r'''[`"']?MEDIA:\s*(?P`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|[^\n]+?\.(?:''' + + media_extensions + + r''')(?=[\s`"',;:)\]}]|$)|\S+)[`"']?''' + ) for match in media_pattern.finditer(text): - path = match.group(1) + path = match.group("path").strip() + if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'": + path = path[1:-1].strip() + path = path.lstrip("`\"'").rstrip("`\"',.;:)}]") attachments.append({"type": "media", "path": path}) return attachments diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 23646545bfcd..7ef67d7a35ca 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -329,6 +329,34 @@ def test_media_tag_supports_unquoted_flac_paths_with_spaces(self): assert media == [("/tmp/Jane Doe/speech.flac", False)] assert cleaned == "" + def test_media_tag_supports_windows_drive_paths_with_spaces(self): + content = r"Here is the file MEDIA:C:\Users\Confera\OneDrive\Nusa Alam Kreasindo\Project\Foo\report.pdf for you." + media, cleaned = BasePlatformAdapter.extract_media(content) + assert media == [ + (r"C:\Users\Confera\OneDrive\Nusa Alam Kreasindo\Project\Foo\report.pdf", False) + ] + assert "MEDIA:" not in cleaned + assert "Alam Kreasindo" not in cleaned + assert "Here is the file" in cleaned + assert "for you." in cleaned + + def test_media_tag_supports_unc_paths_with_spaces(self): + content = r"MEDIA:\\fileserver\shared reports\Q2\map export.kml" + media, cleaned = BasePlatformAdapter.extract_media(content) + assert media == [ + (r"\\fileserver\shared reports\Q2\map export.kml", False) + ] + assert cleaned == "" + + def test_media_tag_supports_structured_data_extensions_with_spaces(self): + content = "MEDIA:/home/user/My Folder/coords.kmz\nMEDIA:/home/user/My Folder/schema.geojson" + media, cleaned = BasePlatformAdapter.extract_media(content) + assert media == [ + ("/home/user/My Folder/coords.kmz", False), + ("/home/user/My Folder/schema.geojson", False), + ] + assert cleaned == "" + def test_as_document_directive_stripped_from_cleaned_text(self): """[[as_document]] is a routing directive — strip it from user-visible text just like [[audio_as_voice]]. Callers detect the @@ -728,4 +756,3 @@ def test_http_proxy_falls_back_without_aiohttp_socks(self): sess_kw, req_kw = proxy_kwargs_for_aiohttp("http://proxy:8080") assert sess_kw == {} assert req_kw == {"proxy": "http://proxy:8080"} - diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index 41d8f40e84d3..84ea8a856377 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -76,6 +76,24 @@ def test_media_mid_sentence(self): assert "generated" in result assert "for you." in result + def test_windows_spaced_media_path_stripped_without_leaking_tail(self): + """Windows MEDIA paths with spaces should be stripped as one token.""" + text = r"Generated MEDIA:C:\Users\Confera\OneDrive\Nusa Alam Kreasindo\Project\Foo\report.pdf for you." + result = GatewayStreamConsumer._clean_for_display(text) + assert "MEDIA:" not in result + assert "Alam Kreasindo" not in result + assert "Generated" in result + assert "for you." in result + + def test_structured_data_media_path_with_spaces_stripped(self): + """GIS / structured-data MEDIA extensions should strip like images.""" + text = "Map ready\nMEDIA:/home/user/My Folder/coords.kmz\nDone" + result = GatewayStreamConsumer._clean_for_display(text) + assert "MEDIA:" not in result + assert "My Folder" not in result + assert "Map ready" in result + assert "Done" in result + def test_preserves_non_media_colons(self): """Normal colons and text with 'MEDIA' as a word aren't stripped.""" text = "The media: files are stored in /tmp. Use social MEDIA carefully." @@ -1780,4 +1798,3 @@ def test_codepoint_only_adapter_falls_back_to_len(self): # auto-attr mock. Verified indirectly by all the other tests in # this file passing — they all use MagicMock adapters. assert consumer is not None - diff --git a/tests/test_mcp_serve.py b/tests/test_mcp_serve.py index 86e3ae0bd383..7ebe4c70b3af 100644 --- a/tests/test_mcp_serve.py +++ b/tests/test_mcp_serve.py @@ -343,6 +343,25 @@ def test_media_tag_in_text(self): assert len(att) == 1 assert att[0] == {"type": "media", "path": "/tmp/out.png"} + def test_windows_spaced_media_tag_in_text(self): + from mcp_serve import _extract_attachments + msg = { + "content": r"Here MEDIA: C:\Users\Confera\OneDrive\Nusa Alam Kreasindo\Project\Foo\report.pdf done" + } + att = _extract_attachments(msg) + assert len(att) == 1 + assert att[0] == { + "type": "media", + "path": r"C:\Users\Confera\OneDrive\Nusa Alam Kreasindo\Project\Foo\report.pdf", + } + + def test_structured_data_media_tag_in_text(self): + from mcp_serve import _extract_attachments + msg = {"content": "Here MEDIA: /home/user/My Folder/coords.kmz done"} + att = _extract_attachments(msg) + assert len(att) == 1 + assert att[0] == {"type": "media", "path": "/home/user/My Folder/coords.kmz"} + def test_multiple_media_tags(self): from mcp_serve import _extract_attachments msg = {"content": "MEDIA: /a.png and MEDIA: /b.mp3"} diff --git a/ui-tui/src/__tests__/markdown.test.ts b/ui-tui/src/__tests__/markdown.test.ts index b2fab9232711..20f61c9ec511 100644 --- a/ui-tui/src/__tests__/markdown.test.ts +++ b/ui-tui/src/__tests__/markdown.test.ts @@ -170,6 +170,12 @@ describe('protocol sentinels', () => { ) expect('`MEDIA:/tmp/a.png`'.match(MEDIA_LINE_RE)?.[1]).toBe('/tmp/a.png') expect('"MEDIA:C:\\files\\a.png"'.match(MEDIA_LINE_RE)?.[1]).toBe('C:\\files\\a.png') + expect('MEDIA:C:\\Users\\Confera\\OneDrive\\Nusa Alam Kreasindo\\Project\\Foo\\report.pdf'.match(MEDIA_LINE_RE)?.[1]).toBe( + 'C:\\Users\\Confera\\OneDrive\\Nusa Alam Kreasindo\\Project\\Foo\\report.pdf' + ) + expect('MEDIA:/home/user/My Folder/coords.kmz'.match(MEDIA_LINE_RE)?.[1]).toBe( + '/home/user/My Folder/coords.kmz' + ) }) it('ignores MEDIA: tokens embedded in prose', () => { diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index c215cd811bf4..e1d7ea37893c 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -80,7 +80,7 @@ const MATH_BLOCK_OPEN_RE = /^\s*(\$\$|\\\[)(.*)$/ const MATH_BLOCK_CLOSE_DOLLAR_RE = /^(.*?)\$\$\s*$/ const MATH_BLOCK_CLOSE_BRACKET_RE = /^(.*?)\\\]\s*$/ -export const MEDIA_LINE_RE = /^\s*[`"']?MEDIA:\s*(\S+?)[`"']?\s*$/ +export const MEDIA_LINE_RE = /^\s*[`"']?MEDIA:\s*(?=\S)(.+?)[`"']?\s*$/ export const AUDIO_DIRECTIVE_RE = /^\s*\[\[audio_as_voice\]\]\s*$/ // Inline markdown tokens, in priority order. The outer regex picks the From 40040c22a4581a7436510dd32bc7a2581c282da4 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 12 May 2026 18:57:58 +1000 Subject: [PATCH 2/2] refactor(media): deduplicate MEDIA extension parsing --- gateway/stream_consumer.py | 11 +------- mcp_serve.py | 19 +++----------- ui-tui/src/components/markdown.tsx | 41 +++++++++++++++++++++++++++++- 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index bee4c04ee000..55b6cff306ae 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -612,16 +612,7 @@ async def run(self) -> None: # Pattern to strip MEDIA: tags (including optional surrounding quotes). # Matches the simple cleanup regex used by the non-streaming path in # gateway/platforms/base.py for post-processing. - _MEDIA_EXTENSIONS_RE = ( - r"png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|" - r"epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa|kmz|kml|" - r"json|xml|html?|geojson|gpx" - ) - _MEDIA_RE = re.compile( - r'''[`"']?MEDIA:\s*(?:`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|[^\n]+?\.(?:''' - + _MEDIA_EXTENSIONS_RE - + r''')(?=[\s`"',;:)\]}]|$)|\S+)[`"']?''' - ) + _MEDIA_RE = _BasePlatformAdapter._MEDIA_TAG_RE @staticmethod def _clean_for_display(text: str) -> str: diff --git a/mcp_serve.py b/mcp_serve.py index 1290006f819d..19450b5cc696 100644 --- a/mcp_serve.py +++ b/mcp_serve.py @@ -32,7 +32,6 @@ import json import logging import os -import re import sys import threading import time @@ -176,21 +175,9 @@ def _extract_attachments(msg: dict) -> List[dict]: # MEDIA: tags in text content text = _extract_message_content(msg) if text: - media_extensions = ( - r"png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|" - r"epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa|kmz|kml|" - r"json|xml|html?|geojson|gpx" - ) - media_pattern = re.compile( - r'''[`"']?MEDIA:\s*(?P`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|[^\n]+?\.(?:''' - + media_extensions - + r''')(?=[\s`"',;:)\]}]|$)|\S+)[`"']?''' - ) - for match in media_pattern.finditer(text): - path = match.group("path").strip() - if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'": - path = path[1:-1].strip() - path = path.lstrip("`\"'").rstrip("`\"',.;:)}]") + from gateway.platforms.base import BasePlatformAdapter + + for path, _ in BasePlatformAdapter.extract_media(text)[0]: attachments.append({"type": "media", "path": path}) return attachments diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index e1d7ea37893c..0802a2042be6 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -80,7 +80,46 @@ const MATH_BLOCK_OPEN_RE = /^\s*(\$\$|\\\[)(.*)$/ const MATH_BLOCK_CLOSE_DOLLAR_RE = /^(.*?)\$\$\s*$/ const MATH_BLOCK_CLOSE_BRACKET_RE = /^(.*?)\\\]\s*$/ -export const MEDIA_LINE_RE = /^\s*[`"']?MEDIA:\s*(?=\S)(.+?)[`"']?\s*$/ +// Keep in sync with BasePlatformAdapter._MEDIA_EXTENSIONS_RE in +// gateway/platforms/base.py. TypeScript cannot import the Python constant. +export const MEDIA_EXTENSIONS = [ + 'png', + 'jpe?g', + 'gif', + 'webp', + 'mp4', + 'mov', + 'avi', + 'mkv', + 'webm', + 'ogg', + 'opus', + 'mp3', + 'wav', + 'm4a', + 'flac', + 'epub', + 'pdf', + 'zip', + 'rar', + '7z', + 'docx?', + 'xlsx?', + 'pptx?', + 'txt', + 'csv', + 'apk', + 'ipa', + 'kmz', + 'kml', + 'json', + 'xml', + 'html?', + 'geojson', + 'gpx', +] +const MEDIA_EXTENSIONS_RE = MEDIA_EXTENSIONS.join('|') +export const MEDIA_LINE_RE = new RegExp(String.raw`^\s*[\`"']?MEDIA:\s*(?=\S)(.+?\.(?:${MEDIA_EXTENSIONS_RE})(?=[\s\`"',;:)\]}]|$)|\S+?)[\`"']?\s*$`) export const AUDIO_DIRECTIVE_RE = /^\s*\[\[audio_as_voice\]\]\s*$/ // Inline markdown tokens, in priority order. The outer regex picks the