diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_processing.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_processing.py index 0fb2a40af..46a133c16 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_processing.py +++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_processing.py @@ -13,7 +13,7 @@ import yaml logger = logging.getLogger(__name__) -_UPLOAD_BLOCK_RE = re.compile(r"<(?Puploaded_files|current_uploads)>[\s\S]*?\n*", re.IGNORECASE) +_UPLOAD_BLOCK_RE = re.compile(r"[\s\S]*?\n*", re.IGNORECASE) _PATTERN_CACHE: dict[tuple[str, str | None], list[re.Pattern[str]]] = {} @@ -186,7 +186,7 @@ def filter_messages_for_memory(messages: list[Any], *, should_keep_hidden_messag if not keep: continue content_str = extract_message_text(msg) - if "" in content_str.lower() or "" in content_str.lower(): + if "" in content_str.lower(): stripped = _UPLOAD_BLOCK_RE.sub("", content_str).strip() if not stripped: skip_next_ai = True diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py index 36c49eedb..0d98edc6c 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py +++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py @@ -754,11 +754,11 @@ def format_conversation_for_update(messages: list[Any]) -> str: text_parts.append(text_val) content = " ".join(text_parts) if text_parts else str(content) - # Strip uploaded_files tags from human messages to avoid persisting + # Strip the upload-context tag from human messages to avoid persisting # ephemeral file path info into long-term memory. Skip the turn entirely # when nothing remains after stripping (upload-only message). if role == "human": - content = re.sub(r"<(?Puploaded_files|current_uploads)>[\s\S]*?\n*", "", str(content)).strip() + content = re.sub(r"[\s\S]*?\n*", "", str(content)).strip() if not content: continue diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py index 8be4a5928..8d827aabd 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py @@ -434,7 +434,7 @@ _UPLOAD_SENTENCE_RE = re.compile( r"upload(?:ed|ing)?(?:\s+\w+){0,3}\s+(?:file|files?|document|documents?|attachment|attachments?)" r"|file\s+upload" r"|/mnt/user-data/uploads/" - r"|<(?:uploaded_files|current_uploads)>" + r"|" r")[^.!?]*[.!?]?\s*", re.IGNORECASE, ) diff --git a/backend/packages/harness/deerflow/agents/memory/backends/mem0/message_filtering.py b/backend/packages/harness/deerflow/agents/memory/backends/mem0/message_filtering.py index 974a253cc..94f3ce634 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/mem0/message_filtering.py +++ b/backend/packages/harness/deerflow/agents/memory/backends/mem0/message_filtering.py @@ -14,7 +14,7 @@ from collections.abc import Mapping from copy import copy from typing import Any -_UPLOAD_BLOCK_RE = re.compile(r"<(?Puploaded_files|current_uploads)>[\s\S]*?\n*", re.IGNORECASE) +_UPLOAD_BLOCK_RE = re.compile(r"[\s\S]*?\n*", re.IGNORECASE) def extract_message_text(message: Any) -> str: @@ -70,7 +70,7 @@ def filter_messages_for_memory(messages: list[Any]) -> list[Any]: if additional_kwargs.get("hide_from_ui") and not _is_human_clarification_response(additional_kwargs): continue text = extract_message_text(msg) - if "" in text.lower() or "" in text.lower(): + if "" in text.lower(): stripped = _UPLOAD_BLOCK_RE.sub("", text).strip() if not stripped: # Upload-only turn: the following AI ack carries no user content. diff --git a/backend/packages/harness/deerflow/agents/middlewares/input_sanitization_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/input_sanitization_middleware.py index 31f433229..976268190 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/input_sanitization_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/input_sanitization_middleware.py @@ -77,7 +77,6 @@ _BLOCKED_TAG_NAMES: frozenset[str] = frozenset( "critical_reminders", "response_style", "citations", - "uploaded_files", # old uploads tag — still processed by deermem for backward-compat "current_uploads", "subagent_system", "skill_system", diff --git a/backend/tests/test_mem0_memory_backend.py b/backend/tests/test_mem0_memory_backend.py index 95e5d93d6..a77a0f7e9 100644 --- a/backend/tests/test_mem0_memory_backend.py +++ b/backend/tests/test_mem0_memory_backend.py @@ -255,7 +255,7 @@ class TestMessageFiltering: assert filter_messages_for_memory([hidden, clarification]) == [clarification] def test_upload_only_human_drops_it_and_following_ai(self) -> None: - upload_only = HumanMessage(content="\nfile.pdf\n") + upload_only = HumanMessage(content="\nfile.pdf\n") ack = AIMessage(content="I see your file") followup = HumanMessage(content="what is in it?") assert filter_messages_for_memory([upload_only, ack, followup]) == [followup] diff --git a/backend/tests/test_memory_upload_filtering.py b/backend/tests/test_memory_upload_filtering.py index 3dac003a1..6f20de194 100644 --- a/backend/tests/test_memory_upload_filtering.py +++ b/backend/tests/test_memory_upload_filtering.py @@ -16,9 +16,13 @@ from deerflow.agents.memory.backends.deermem.deermem.core.updater import _strip_ # Helpers # --------------------------------------------------------------------------- -_UPLOAD_BLOCK = "\nThe following files have been uploaded and are available for use:\n\n- filename: secret.txt\n path: /mnt/user-data/uploads/abc123/secret.txt\n size: 42 bytes\n" +# ``_UPLOAD_BLOCK`` uses the tag UploadsMiddleware actually emits since #4174. +# ``_LEGACY_UPLOAD_BLOCK`` is the pre-#4174 tag: after the #4212 cleanup it is +# treated as ordinary user content by the memory pipeline (see the dedicated +# scope-decision tests below). +_UPLOAD_BLOCK = "\nThe following files have been uploaded and are available for use:\n\n- filename: secret.txt\n path: /mnt/user-data/uploads/abc123/secret.txt\n size: 42 bytes\n" -_CURRENT_UPLOADS_BLOCK = "\nThe following files have been uploaded in this run:\n\n- filename: report.pdf\n path: /mnt/user-data/uploads/def456/report.pdf\n size: 2048 bytes\n" +_LEGACY_UPLOAD_BLOCK = "\nThe following files have been uploaded and are available for use:\n\n- filename: report.pdf\n path: /mnt/user-data/uploads/def456/report.pdf\n size: 2048 bytes\n" def _human(text: str) -> HumanMessage: @@ -41,7 +45,7 @@ class TestFilterMessagesForMemory: # --- upload-only turns are excluded --- def test_upload_only_turn_is_excluded(self): - """A human turn containing only (no real question) + """A human turn containing only the upload-context tag (no real question) and its paired AI response must both be dropped.""" msgs = [ _human(_UPLOAD_BLOCK), @@ -50,16 +54,6 @@ class TestFilterMessagesForMemory: result = filter_messages_for_memory(msgs) assert result == [] - def test_upload_only_turn_is_excluded_current_uploads(self): - """Same as above but with — the tag actually emitted - by UploadsMiddleware in production.""" - msgs = [ - _human(_CURRENT_UPLOADS_BLOCK), - _ai("I have read the report. It says: Q3 revenue up 12%."), - ] - result = filter_messages_for_memory(msgs) - assert result == [] - def test_upload_with_real_question_preserves_question(self): """When the user asks a question alongside an upload, the question text must reach the memory queue (upload block stripped, AI response kept).""" @@ -72,25 +66,21 @@ class TestFilterMessagesForMemory: assert len(result) == 2 human_result = result[0] - assert "" not in human_result.content + assert "" not in human_result.content assert "What does this file contain?" in human_result.content assert result[1].content == "The file contains: Hello DeerFlow." - def test_upload_with_question_preserves_question_current_uploads(self): - """Same as above but with — the tag actually emitted - by UploadsMiddleware in production.""" - combined = _CURRENT_UPLOADS_BLOCK + "\n\nSummarise this report please." + def test_legacy_uploaded_files_block_is_plain_user_content(self): + """Scope decision for #4212: the pre-#4174 ```` tag is no + longer special-cased. A turn containing only that tag flows through as + ordinary user content instead of being silently dropped.""" msgs = [ - _human(combined), - _ai("The report indicates Q3 revenue is up 12%."), + _human(_LEGACY_UPLOAD_BLOCK), + _ai("I see a legacy upload block."), ] result = filter_messages_for_memory(msgs) - assert len(result) == 2 - human_result = result[0] - assert "" not in human_result.content - assert "Summarise this report please." in human_result.content - assert result[1].content == "The report indicates Q3 revenue is up 12%." + assert result[0].content == _LEGACY_UPLOAD_BLOCK # --- non-upload turns pass through unchanged --- @@ -159,7 +149,7 @@ class TestFilterMessagesForMemory: result = filter_messages_for_memory(msgs) all_content = " ".join(m.content for m in result if isinstance(m.content, str)) assert "/mnt/user-data/uploads/" not in all_content - assert "" not in all_content + assert "" not in all_content # --- hide_from_ui messages are excluded --- @@ -399,6 +389,13 @@ class TestStripUploadMentionsFromMemory: result = _strip_upload_mentions_from_memory(mem) assert len(result["facts"]) == 2 + def test_legacy_uploaded_files_tag_sentence_preserved(self): + """Scope decision for #4212: only the ```` tag is + stripped from stored summaries; the pre-#4174 tag is ordinary text.""" + mem = self._make_memory("User works on reports. session.pdf is old context.") + result = _strip_upload_mentions_from_memory(mem) + assert "session.pdf" in result["user"]["topOfMind"]["summary"] + def test_empty_memory_handled_gracefully(self): mem = {"user": {}, "history": {}, "facts": []} result = _strip_upload_mentions_from_memory(mem) diff --git a/backend/tests/test_slash_skills.py b/backend/tests/test_slash_skills.py index d2160ae12..87517af12 100644 --- a/backend/tests/test_slash_skills.py +++ b/backend/tests/test_slash_skills.py @@ -508,7 +508,7 @@ def test_skill_activation_middleware_uses_original_user_content_when_uploads_are middleware = SkillActivationMiddleware(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN) original = HumanMessage( - content="\n- report.pdf\n\n\n/data-analysis 分析这个文档", + content="\n- report.pdf\n\n\n/data-analysis 分析这个文档", id="msg-1", additional_kwargs={ORIGINAL_USER_CONTENT_KEY: "/data-analysis 分析这个文档"}, ) diff --git a/backend/tests/test_thread_regenerate_prepare.py b/backend/tests/test_thread_regenerate_prepare.py index c99bf63f6..59eba4ff9 100644 --- a/backend/tests/test_thread_regenerate_prepare.py +++ b/backend/tests/test_thread_regenerate_prepare.py @@ -365,7 +365,7 @@ def test_prepare_regenerate_payload_returns_clean_input_and_base_checkpoint(): human = HumanMessage( id="human-1", - content="injected\n\n/data-analysis analyze data.csv", + content="injected\n\n/data-analysis analyze data.csv", additional_kwargs={ ORIGINAL_USER_CONTENT_KEY: "/data-analysis analyze data.csv", "files": [{"filename": "data.csv", "path": "/mnt/user-data/uploads/data.csv"}], @@ -649,7 +649,7 @@ def test_prepare_edit_regenerate_payload_returns_new_human_and_edit_metadata(): human = HumanMessage( id="human-1", - content="injected\n\noriginal question", + content="injected\n\noriginal question", name="researcher", additional_kwargs={ ORIGINAL_USER_CONTENT_KEY: "original question", diff --git a/frontend/public/demo/threads/21cfea46-34bd-4aa6-9e1f-3009452fbeb9/thread.json b/frontend/public/demo/threads/21cfea46-34bd-4aa6-9e1f-3009452fbeb9/thread.json index bb918ca52..9cc1c3af6 100644 --- a/frontend/public/demo/threads/21cfea46-34bd-4aa6-9e1f-3009452fbeb9/thread.json +++ b/frontend/public/demo/threads/21cfea46-34bd-4aa6-9e1f-3009452fbeb9/thread.json @@ -15,7 +15,7 @@ "id": "47dcc555-9787-4ce6-88fd-cb4d728243ac" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", @@ -108,7 +108,7 @@ "id": "800c7d6e-d553-4f30-90e1-5c8d1d71c083" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", @@ -147,7 +147,7 @@ "id": "e46db0ab-38c5-4f02-bbdd-fdef7c7a708c" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", diff --git a/frontend/public/demo/threads/3823e443-4e2b-4679-b496-a9506eae462b/thread.json b/frontend/public/demo/threads/3823e443-4e2b-4679-b496-a9506eae462b/thread.json index 946868f62..ef793b92d 100644 --- a/frontend/public/demo/threads/3823e443-4e2b-4679-b496-a9506eae462b/thread.json +++ b/frontend/public/demo/threads/3823e443-4e2b-4679-b496-a9506eae462b/thread.json @@ -15,7 +15,7 @@ "id": "ef6ba42d-88c7-4f64-80c0-e3d0dc8fc381" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", @@ -103,7 +103,7 @@ "id": "1a49946d-9b79-4805-a959-5eb983010982" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", diff --git a/frontend/public/demo/threads/4f3e55ee-f853-43db-bfb3-7d1a411f03cb/thread.json b/frontend/public/demo/threads/4f3e55ee-f853-43db-bfb3-7d1a411f03cb/thread.json index 4ad3afe7a..3ce6f0608 100644 --- a/frontend/public/demo/threads/4f3e55ee-f853-43db-bfb3-7d1a411f03cb/thread.json +++ b/frontend/public/demo/threads/4f3e55ee-f853-43db-bfb3-7d1a411f03cb/thread.json @@ -15,7 +15,7 @@ "id": "bd5f52dd-e7c1-4a05-9511-870fb47c6950" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", diff --git a/frontend/public/demo/threads/7cfa5f8f-a2f8-47ad-acbd-da7137baf990/thread.json b/frontend/public/demo/threads/7cfa5f8f-a2f8-47ad-acbd-da7137baf990/thread.json index c42844fa7..5c988663c 100644 --- a/frontend/public/demo/threads/7cfa5f8f-a2f8-47ad-acbd-da7137baf990/thread.json +++ b/frontend/public/demo/threads/7cfa5f8f-a2f8-47ad-acbd-da7137baf990/thread.json @@ -15,7 +15,7 @@ "id": "c6794328-e667-41fd-8067-b7314bcc4838" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", @@ -511,7 +511,7 @@ "id": "177797fd-7a9f-480a-8c6e-005dd2db3e59" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", diff --git a/frontend/public/demo/threads/7f9dc56c-e49c-4671-a3d2-c492ff4dce0c/thread.json b/frontend/public/demo/threads/7f9dc56c-e49c-4671-a3d2-c492ff4dce0c/thread.json index a3f213479..bf795bb32 100644 --- a/frontend/public/demo/threads/7f9dc56c-e49c-4671-a3d2-c492ff4dce0c/thread.json +++ b/frontend/public/demo/threads/7f9dc56c-e49c-4671-a3d2-c492ff4dce0c/thread.json @@ -15,7 +15,7 @@ "id": "37aacd32-f56f-4bb3-8184-ebcafde0bd14" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", diff --git a/frontend/public/demo/threads/90040b36-7eba-4b97-ba89-02c3ad47a8b9/thread.json b/frontend/public/demo/threads/90040b36-7eba-4b97-ba89-02c3ad47a8b9/thread.json index 17a0eeb2e..a6c75e6cd 100644 --- a/frontend/public/demo/threads/90040b36-7eba-4b97-ba89-02c3ad47a8b9/thread.json +++ b/frontend/public/demo/threads/90040b36-7eba-4b97-ba89-02c3ad47a8b9/thread.json @@ -15,7 +15,7 @@ "id": "82879088-d086-4aa3-aa90-9d1cfaf25a12" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", @@ -142,7 +142,7 @@ "id": "f97120b2-0071-4454-85b8-8bb636833401" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", @@ -375,7 +375,7 @@ "id": "9da6213d-a1eb-4170-a61d-8378e6a680a1" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", diff --git a/frontend/public/demo/threads/ad76c455-5bf9-4335-8517-fc03834ab828/thread.json b/frontend/public/demo/threads/ad76c455-5bf9-4335-8517-fc03834ab828/thread.json index 3fdd8edbb..43b11beb6 100644 --- a/frontend/public/demo/threads/ad76c455-5bf9-4335-8517-fc03834ab828/thread.json +++ b/frontend/public/demo/threads/ad76c455-5bf9-4335-8517-fc03834ab828/thread.json @@ -15,7 +15,7 @@ "id": "ac8f27cd-0f2e-4a82-a432-f4b37d18a846" }, { - "content": "\nThe following files have been uploaded and are available for use:\n\n- titanic.csv (58.9 KB)\n Path: /mnt/user-data/uploads/titanic.csv\n\nYou can read these files using the `read_file` tool with the paths shown above.\n", + "content": "\nThe following files have been uploaded and are available for use:\n\n- titanic.csv (58.9 KB)\n Path: /mnt/user-data/uploads/titanic.csv\n\nYou can read these files using the `read_file` tool with the paths shown above.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", diff --git a/frontend/public/demo/threads/c02bb4d5-4202-490e-ae8f-ff4864fc0d2e/thread.json b/frontend/public/demo/threads/c02bb4d5-4202-490e-ae8f-ff4864fc0d2e/thread.json index a20715961..2924a1ba5 100644 --- a/frontend/public/demo/threads/c02bb4d5-4202-490e-ae8f-ff4864fc0d2e/thread.json +++ b/frontend/public/demo/threads/c02bb4d5-4202-490e-ae8f-ff4864fc0d2e/thread.json @@ -15,7 +15,7 @@ "id": "69411c14-efea-4c7f-a322-2dd541b98fda" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", @@ -565,7 +565,7 @@ "id": "5b5a187a-5b2d-4c9e-b6f7-817fe9c12330" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", diff --git a/frontend/public/demo/threads/d3e5adaf-084c-4dd5-9d29-94f1d6bccd98/thread.json b/frontend/public/demo/threads/d3e5adaf-084c-4dd5-9d29-94f1d6bccd98/thread.json index 47a09a054..f7ed80bb5 100644 --- a/frontend/public/demo/threads/d3e5adaf-084c-4dd5-9d29-94f1d6bccd98/thread.json +++ b/frontend/public/demo/threads/d3e5adaf-084c-4dd5-9d29-94f1d6bccd98/thread.json @@ -15,7 +15,7 @@ "id": "7093f725-fdee-47b0-b135-abeaea804aff" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", diff --git a/frontend/public/demo/threads/f4125791-0128-402a-8ca9-50e0947557e4/thread.json b/frontend/public/demo/threads/f4125791-0128-402a-8ca9-50e0947557e4/thread.json index e12a09c60..a04aa4c83 100644 --- a/frontend/public/demo/threads/f4125791-0128-402a-8ca9-50e0947557e4/thread.json +++ b/frontend/public/demo/threads/f4125791-0128-402a-8ca9-50e0947557e4/thread.json @@ -15,7 +15,7 @@ "id": "6409a240-5eeb-4df5-8681-4ad80a1daf7e" }, { - "content": "\nNo files have been uploaded yet.\n", + "content": "\nNo files have been uploaded yet.\n", "additional_kwargs": {}, "response_metadata": {}, "type": "system", diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 05bb4c0f5..c7425b8c2 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -430,6 +430,11 @@ function MessageContent_({ rawContent.includes("") ) { // If the content contains an upload context tag, we return the parsed files from the content for backward compatibility. + // is display-only compat for pre-#4174 history (#4212). + // Accepted tradeoff (review): a live user typing the legacy spelling can + // fabricate chips / hide their own message text — display-only and + // self-inflicted, no backend semantics. Age-gating the legacy spelling + // is a possible follow-up if this ever matters. return parseUploadedFiles(rawContent); } return null; diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index 17443f6be..052e1cedf 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -846,6 +846,18 @@ export interface FileInMessage { * Strip backend-injected human context tags from message content. * Kept under its historical name because callers use it for uploaded-file * display cleanup. + * + * Display-only backward compatibility for #4212: ```` is no + * longer emitted by the backend and is treated as plain content by the + * memory/sanitization pipelines, but threads persisted before #4174 still + * carry legacy blocks in their history. This display/export layer keeps + * stripping it so old threads render cleanly instead of showing raw XML + * with server-side upload paths. + * + * Accepted tradeoff (review): a live user typing the legacy spelling can + * hide their own message text / fabricate file chips — display-only and + * self-inflicted, with no backend semantics. Age-gating the legacy + * spelling is a possible follow-up if this ever matters. */ export function stripUploadedFilesTag(content: string): string { return content @@ -862,8 +874,9 @@ export function stripUploadedFilesTag(content: string): string { * * These markers are *not* user copy — they come from: * - * - ``UploadsMiddleware`` → ```` (```` - * before #4174; still emitted by IM channels and present in history) + * - ``UploadsMiddleware`` → ```` (```` is + * the pre-#4174 spelling, still stripped here for display/export only so + * legacy history does not leak raw blocks or server paths — see #4212) * - ``SkillActivationMiddleware`` → ```` * - ``DynamicContextMiddleware`` → ```` (carrying * ```` / ```` inside) @@ -926,8 +939,9 @@ function parseHumanReadableSize(raw: string): number { } export function parseUploadedFiles(content: string): FileInMessage[] { - // Match the upload context block; the tag name depends on backend version - // ( since #4174, before / on IM paths). + // Match the upload context block. is what + // UploadsMiddleware emits (#4174); is kept for + // display-only backward compatibility with pre-#4174 history (#4212). const uploadedFilesRegex = /<(current_uploads|uploaded_files)>([\s\S]*?)<\/\1>/; // eslint-disable-next-line @typescript-eslint/prefer-regexp-exec diff --git a/frontend/src/core/threads/export.ts b/frontend/src/core/threads/export.ts index ce86ec9a0..af6648a21 100644 --- a/frontend/src/core/threads/export.ts +++ b/frontend/src/core/threads/export.ts @@ -141,7 +141,8 @@ function buildJSONMessage( ): JSONExportMessage | null { // Run the same sanitiser the Markdown path uses so the JSON `content` // field never carries inline `...` wrappers, content-array - // thinking blocks, `` markers, or other internal payloads. + // thinking blocks, ``/`` markers, or other + // internal payloads. const content = formatMessageContent(msg); const reasoning = options.includeReasoning && msg.type === "ai" diff --git a/frontend/tests/unit/core/messages/utils.test.ts b/frontend/tests/unit/core/messages/utils.test.ts index a1da2416f..0bae298ff 100644 --- a/frontend/tests/unit/core/messages/utils.test.ts +++ b/frontend/tests/unit/core/messages/utils.test.ts @@ -584,9 +584,12 @@ describe("isHiddenFromUIMessage", () => { }); describe("human message internal context stripping", () => { - test("strips uploaded file context from copy data", () => { + test("strips legacy uploaded_files context from copy data", () => { + // Display-only backward compatibility (#4212): pre-#4174 history still + // carries blocks, which copy data must strip rather + // than leak as raw XML with server-side paths. const message = { - id: "human-with-upload", + id: "human-with-legacy-upload", type: "human", content: "\nThe following files were uploaded in this message:\n\n- paper.pdf (1.0 MB)\n Path: /mnt/user-data/uploads/paper.pdf\n\n\nSummarize this paper", diff --git a/frontend/tests/unit/core/streamdown/preprocess.test.ts b/frontend/tests/unit/core/streamdown/preprocess.test.ts index 2996625e4..b0395e19a 100644 --- a/frontend/tests/unit/core/streamdown/preprocess.test.ts +++ b/frontend/tests/unit/core/streamdown/preprocess.test.ts @@ -321,7 +321,16 @@ test("stripLeakedSystemTags handles no tags present", () => { expect(stripLeakedSystemTags(input)).toBe(input); }); -test("stripLeakedSystemTags strips tag", () => { +test("stripLeakedSystemTags strips tag", () => { + expect( + stripLeakedSystemTags("file.pdf"), + ).toBe("file.pdf"); +}); + +test("stripLeakedSystemTags strips legacy tag", () => { + // Display-only backward compatibility (#4212): pre-#4174 history still + // carries blocks; the leaked-tag stripper keeps handling + // the legacy spelling so old threads do not render raw XML. expect( stripLeakedSystemTags("file.pdf"), ).toBe("file.pdf"); diff --git a/frontend/tests/unit/core/threads/export.test.ts b/frontend/tests/unit/core/threads/export.test.ts index 09f42217d..5425ceba3 100644 --- a/frontend/tests/unit/core/threads/export.test.ts +++ b/frontend/tests/unit/core/threads/export.test.ts @@ -218,10 +218,24 @@ describe("formatThreadAsJSON", () => { expect(raw).toContain("final visible text"); }); - it("strips markers from content", () => { + it("strips markers from content", () => { + const message = human( + "real prompt\n\n/mnt/user-data/uploads/secret.pdf\n", + { id: "h-clean" } as Partial, + ); + const raw = formatThreadAsJSON(makeThread(), [message]); + expect(raw).not.toContain(""); + expect(raw).not.toContain("secret.pdf"); + expect(raw).toContain("real prompt"); + }); + + it("strips legacy markers from content", () => { + // Display-only backward compatibility (#4212): pre-#4174 history still + // carries blocks; exports must keep stripping the + // legacy spelling so server-side upload paths never leak. const message = human( "real prompt\n\n/mnt/user-data/uploads/secret.pdf\n", - { id: "h-clean" } as Partial, + { id: "h-legacy-clean" } as Partial, ); const raw = formatThreadAsJSON(makeThread(), [message]); expect(raw).not.toContain("");