fix(messages): drop legacy <uploaded_files> tag handling (#4826)

* fix(messages): drop legacy <uploaded_files> tag handling (#4212)

PR #4174 unified upload-context injection on <current_uploads> (IM and web
both flow through UploadsMiddleware), and #4632 documented the current
path. This removes the remaining backward-compat parsing of the
pre-#4174 <uploaded_files> tag, the final cleanup item tracked by the
issue:

- deermem: only <current_uploads> is stripped from human turns before
  memory persistence, and the upload-sentence scrubber drops the legacy
  tag alternative.
- mem0: the mirrored message filter recognises only <current_uploads>.
- InputSanitizationMiddleware: remove the legacy tag from the blocked-tag
  denylist (it existed only because deermem parsed the old tag).
- frontend: stripUploadedFilesTag / stripInternalMarkers /
  parseUploadedFiles and the message-list fallback parse only
  <current_uploads>; demo thread fixtures are migrated to the current tag.

Scope decision: a <uploaded_files> block in pre-#4174 history is now
treated as ordinary user content (pinned by tests in both layers) instead
of being silently dropped or stripped.

* style: apply prettier formatting to stripUploadedFilesTag

* fix(uploads): keep legacy <uploaded_files> stripping for display/export only

Addresses review feedback on #4826: removing the legacy tag from the
frontend display layer made pre-#4174 threads render raw <uploaded_files>
XML (with server-side upload paths) in chat, copy data, and JSON exports.

The backend cleanup stands — memory pipelines and the sanitization denylist
treat only <current_uploads> as an internal marker. The frontend keeps the
legacy spelling in its display/export-only utilities
(stripUploadedFilesTag / INTERNAL_MARKER_TAGS / parseUploadedFiles and the
message-list fallback) so old history renders cleanly without leaking
internal paths, while the memory/sanitization scope-decision tests remain
unchanged.

Frontend tests now pin both spellings: <current_uploads> and legacy
<uploaded_files> are stripped from copy data, markdown leak-stripping, and
JSON exports.

* docs(ui): record accepted display-spoof tradeoff for legacy upload tag

Review note (willem-bd): since <uploaded_files> is off the sanitization
denylist, a live user can type the legacy spelling and fabricate file
chips / hide their own message text in display. Display-only and
self-inflicted with no backend semantics, so it is accepted for now;
documented at both the message-list fallback and stripUploadedFilesTag.
Age-gating the legacy spelling remains a possible follow-up.

---------

Co-authored-by: betterkite <313258397+betterkite@users.noreply.github.com>
This commit is contained in:
betterkite 2026-09-01 15:26:33 +08:00 committed by GitHub
parent cdc886ae85
commit b552b5015c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 107 additions and 65 deletions

View File

@ -13,7 +13,7 @@ import yaml
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_UPLOAD_BLOCK_RE = re.compile(r"<(?P<tag>uploaded_files|current_uploads)>[\s\S]*?</(?P=tag)>\n*", re.IGNORECASE) _UPLOAD_BLOCK_RE = re.compile(r"<current_uploads>[\s\S]*?</current_uploads>\n*", re.IGNORECASE)
_PATTERN_CACHE: dict[tuple[str, str | None], list[re.Pattern[str]]] = {} _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: if not keep:
continue continue
content_str = extract_message_text(msg) content_str = extract_message_text(msg)
if "<uploaded_files>" in content_str.lower() or "<current_uploads>" in content_str.lower(): if "<current_uploads>" in content_str.lower():
stripped = _UPLOAD_BLOCK_RE.sub("", content_str).strip() stripped = _UPLOAD_BLOCK_RE.sub("", content_str).strip()
if not stripped: if not stripped:
skip_next_ai = True skip_next_ai = True

View File

@ -754,11 +754,11 @@ def format_conversation_for_update(messages: list[Any]) -> str:
text_parts.append(text_val) text_parts.append(text_val)
content = " ".join(text_parts) if text_parts else str(content) 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 # ephemeral file path info into long-term memory. Skip the turn entirely
# when nothing remains after stripping (upload-only message). # when nothing remains after stripping (upload-only message).
if role == "human": if role == "human":
content = re.sub(r"<(?P<tag>uploaded_files|current_uploads)>[\s\S]*?</(?P=tag)>\n*", "", str(content)).strip() content = re.sub(r"<current_uploads>[\s\S]*?</current_uploads>\n*", "", str(content)).strip()
if not content: if not content:
continue continue

View File

@ -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"upload(?:ed|ing)?(?:\s+\w+){0,3}\s+(?:file|files?|document|documents?|attachment|attachments?)"
r"|file\s+upload" r"|file\s+upload"
r"|/mnt/user-data/uploads/" r"|/mnt/user-data/uploads/"
r"|<(?:uploaded_files|current_uploads)>" r"|<current_uploads>"
r")[^.!?]*[.!?]?\s*", r")[^.!?]*[.!?]?\s*",
re.IGNORECASE, re.IGNORECASE,
) )

View File

@ -14,7 +14,7 @@ from collections.abc import Mapping
from copy import copy from copy import copy
from typing import Any from typing import Any
_UPLOAD_BLOCK_RE = re.compile(r"<(?P<tag>uploaded_files|current_uploads)>[\s\S]*?</(?P=tag)>\n*", re.IGNORECASE) _UPLOAD_BLOCK_RE = re.compile(r"<current_uploads>[\s\S]*?</current_uploads>\n*", re.IGNORECASE)
def extract_message_text(message: Any) -> str: 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): if additional_kwargs.get("hide_from_ui") and not _is_human_clarification_response(additional_kwargs):
continue continue
text = extract_message_text(msg) text = extract_message_text(msg)
if "<uploaded_files>" in text.lower() or "<current_uploads>" in text.lower(): if "<current_uploads>" in text.lower():
stripped = _UPLOAD_BLOCK_RE.sub("", text).strip() stripped = _UPLOAD_BLOCK_RE.sub("", text).strip()
if not stripped: if not stripped:
# Upload-only turn: the following AI ack carries no user content. # Upload-only turn: the following AI ack carries no user content.

View File

@ -77,7 +77,6 @@ _BLOCKED_TAG_NAMES: frozenset[str] = frozenset(
"critical_reminders", "critical_reminders",
"response_style", "response_style",
"citations", "citations",
"uploaded_files", # old uploads tag — still processed by deermem for backward-compat
"current_uploads", "current_uploads",
"subagent_system", "subagent_system",
"skill_system", "skill_system",

View File

@ -255,7 +255,7 @@ class TestMessageFiltering:
assert filter_messages_for_memory([hidden, clarification]) == [clarification] assert filter_messages_for_memory([hidden, clarification]) == [clarification]
def test_upload_only_human_drops_it_and_following_ai(self) -> None: def test_upload_only_human_drops_it_and_following_ai(self) -> None:
upload_only = HumanMessage(content="<uploaded_files>\nfile.pdf\n</uploaded_files>") upload_only = HumanMessage(content="<current_uploads>\nfile.pdf\n</current_uploads>")
ack = AIMessage(content="I see your file") ack = AIMessage(content="I see your file")
followup = HumanMessage(content="what is in it?") followup = HumanMessage(content="what is in it?")
assert filter_messages_for_memory([upload_only, ack, followup]) == [followup] assert filter_messages_for_memory([upload_only, ack, followup]) == [followup]

View File

@ -16,9 +16,13 @@ from deerflow.agents.memory.backends.deermem.deermem.core.updater import _strip_
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_UPLOAD_BLOCK = "<uploaded_files>\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</uploaded_files>" # ``_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 = "<current_uploads>\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>"
_CURRENT_UPLOADS_BLOCK = "<current_uploads>\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</current_uploads>" _LEGACY_UPLOAD_BLOCK = "<uploaded_files>\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</uploaded_files>"
def _human(text: str) -> HumanMessage: def _human(text: str) -> HumanMessage:
@ -41,7 +45,7 @@ class TestFilterMessagesForMemory:
# --- upload-only turns are excluded --- # --- upload-only turns are excluded ---
def test_upload_only_turn_is_excluded(self): def test_upload_only_turn_is_excluded(self):
"""A human turn containing only <uploaded_files> (no real question) """A human turn containing only the upload-context tag (no real question)
and its paired AI response must both be dropped.""" and its paired AI response must both be dropped."""
msgs = [ msgs = [
_human(_UPLOAD_BLOCK), _human(_UPLOAD_BLOCK),
@ -50,16 +54,6 @@ class TestFilterMessagesForMemory:
result = filter_messages_for_memory(msgs) result = filter_messages_for_memory(msgs)
assert result == [] assert result == []
def test_upload_only_turn_is_excluded_current_uploads(self):
"""Same as above but with <current_uploads> — 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): def test_upload_with_real_question_preserves_question(self):
"""When the user asks a question alongside an upload, the question text """When the user asks a question alongside an upload, the question text
must reach the memory queue (upload block stripped, AI response kept).""" must reach the memory queue (upload block stripped, AI response kept)."""
@ -72,25 +66,21 @@ class TestFilterMessagesForMemory:
assert len(result) == 2 assert len(result) == 2
human_result = result[0] human_result = result[0]
assert "<uploaded_files>" not in human_result.content assert "<current_uploads>" not in human_result.content
assert "What does this file contain?" in human_result.content assert "What does this file contain?" in human_result.content
assert result[1].content == "The file contains: Hello DeerFlow." assert result[1].content == "The file contains: Hello DeerFlow."
def test_upload_with_question_preserves_question_current_uploads(self): def test_legacy_uploaded_files_block_is_plain_user_content(self):
"""Same as above but with <current_uploads> — the tag actually emitted """Scope decision for #4212: the pre-#4174 ``<uploaded_files>`` tag is no
by UploadsMiddleware in production.""" longer special-cased. A turn containing only that tag flows through as
combined = _CURRENT_UPLOADS_BLOCK + "\n\nSummarise this report please." ordinary user content instead of being silently dropped."""
msgs = [ msgs = [
_human(combined), _human(_LEGACY_UPLOAD_BLOCK),
_ai("The report indicates Q3 revenue is up 12%."), _ai("I see a legacy upload block."),
] ]
result = filter_messages_for_memory(msgs) result = filter_messages_for_memory(msgs)
assert len(result) == 2 assert len(result) == 2
human_result = result[0] assert result[0].content == _LEGACY_UPLOAD_BLOCK
assert "<current_uploads>" 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%."
# --- non-upload turns pass through unchanged --- # --- non-upload turns pass through unchanged ---
@ -159,7 +149,7 @@ class TestFilterMessagesForMemory:
result = filter_messages_for_memory(msgs) result = filter_messages_for_memory(msgs)
all_content = " ".join(m.content for m in result if isinstance(m.content, str)) all_content = " ".join(m.content for m in result if isinstance(m.content, str))
assert "/mnt/user-data/uploads/" not in all_content assert "/mnt/user-data/uploads/" not in all_content
assert "<uploaded_files>" not in all_content assert "<current_uploads>" not in all_content
# --- hide_from_ui messages are excluded --- # --- hide_from_ui messages are excluded ---
@ -399,6 +389,13 @@ class TestStripUploadMentionsFromMemory:
result = _strip_upload_mentions_from_memory(mem) result = _strip_upload_mentions_from_memory(mem)
assert len(result["facts"]) == 2 assert len(result["facts"]) == 2
def test_legacy_uploaded_files_tag_sentence_preserved(self):
"""Scope decision for #4212: only the ``<current_uploads>`` tag is
stripped from stored summaries; the pre-#4174 tag is ordinary text."""
mem = self._make_memory("User works on reports. <uploaded_files>session.pdf</uploaded_files> is old context.")
result = _strip_upload_mentions_from_memory(mem)
assert "<uploaded_files>session.pdf</uploaded_files>" in result["user"]["topOfMind"]["summary"]
def test_empty_memory_handled_gracefully(self): def test_empty_memory_handled_gracefully(self):
mem = {"user": {}, "history": {}, "facts": []} mem = {"user": {}, "history": {}, "facts": []}
result = _strip_upload_mentions_from_memory(mem) result = _strip_upload_mentions_from_memory(mem)

View File

@ -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) middleware = SkillActivationMiddleware(slash_source_owner_token=_SLASH_SOURCE_OWNER_TOKEN)
original = HumanMessage( original = HumanMessage(
content="<uploaded_files>\n- report.pdf\n</uploaded_files>\n\n/data-analysis 分析这个文档", content="<current_uploads>\n- report.pdf\n</current_uploads>\n\n/data-analysis 分析这个文档",
id="msg-1", id="msg-1",
additional_kwargs={ORIGINAL_USER_CONTENT_KEY: "/data-analysis 分析这个文档"}, additional_kwargs={ORIGINAL_USER_CONTENT_KEY: "/data-analysis 分析这个文档"},
) )

View File

@ -365,7 +365,7 @@ def test_prepare_regenerate_payload_returns_clean_input_and_base_checkpoint():
human = HumanMessage( human = HumanMessage(
id="human-1", id="human-1",
content="<uploaded_files>injected</uploaded_files>\n\n/data-analysis analyze data.csv", content="<current_uploads>injected</current_uploads>\n\n/data-analysis analyze data.csv",
additional_kwargs={ additional_kwargs={
ORIGINAL_USER_CONTENT_KEY: "/data-analysis analyze data.csv", ORIGINAL_USER_CONTENT_KEY: "/data-analysis analyze data.csv",
"files": [{"filename": "data.csv", "path": "/mnt/user-data/uploads/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( human = HumanMessage(
id="human-1", id="human-1",
content="<uploaded_files>injected</uploaded_files>\n\noriginal question", content="<current_uploads>injected</current_uploads>\n\noriginal question",
name="researcher", name="researcher",
additional_kwargs={ additional_kwargs={
ORIGINAL_USER_CONTENT_KEY: "original question", ORIGINAL_USER_CONTENT_KEY: "original question",

View File

@ -15,7 +15,7 @@
"id": "47dcc555-9787-4ce6-88fd-cb4d728243ac" "id": "47dcc555-9787-4ce6-88fd-cb4d728243ac"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",
@ -108,7 +108,7 @@
"id": "800c7d6e-d553-4f30-90e1-5c8d1d71c083" "id": "800c7d6e-d553-4f30-90e1-5c8d1d71c083"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",
@ -147,7 +147,7 @@
"id": "e46db0ab-38c5-4f02-bbdd-fdef7c7a708c" "id": "e46db0ab-38c5-4f02-bbdd-fdef7c7a708c"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",

View File

@ -15,7 +15,7 @@
"id": "ef6ba42d-88c7-4f64-80c0-e3d0dc8fc381" "id": "ef6ba42d-88c7-4f64-80c0-e3d0dc8fc381"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",
@ -103,7 +103,7 @@
"id": "1a49946d-9b79-4805-a959-5eb983010982" "id": "1a49946d-9b79-4805-a959-5eb983010982"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",

View File

@ -15,7 +15,7 @@
"id": "bd5f52dd-e7c1-4a05-9511-870fb47c6950" "id": "bd5f52dd-e7c1-4a05-9511-870fb47c6950"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",

View File

@ -15,7 +15,7 @@
"id": "c6794328-e667-41fd-8067-b7314bcc4838" "id": "c6794328-e667-41fd-8067-b7314bcc4838"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",
@ -511,7 +511,7 @@
"id": "177797fd-7a9f-480a-8c6e-005dd2db3e59" "id": "177797fd-7a9f-480a-8c6e-005dd2db3e59"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",

View File

@ -15,7 +15,7 @@
"id": "37aacd32-f56f-4bb3-8184-ebcafde0bd14" "id": "37aacd32-f56f-4bb3-8184-ebcafde0bd14"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",

View File

@ -15,7 +15,7 @@
"id": "82879088-d086-4aa3-aa90-9d1cfaf25a12" "id": "82879088-d086-4aa3-aa90-9d1cfaf25a12"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",
@ -142,7 +142,7 @@
"id": "f97120b2-0071-4454-85b8-8bb636833401" "id": "f97120b2-0071-4454-85b8-8bb636833401"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",
@ -375,7 +375,7 @@
"id": "9da6213d-a1eb-4170-a61d-8378e6a680a1" "id": "9da6213d-a1eb-4170-a61d-8378e6a680a1"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",

View File

@ -15,7 +15,7 @@
"id": "ac8f27cd-0f2e-4a82-a432-f4b37d18a846" "id": "ac8f27cd-0f2e-4a82-a432-f4b37d18a846"
}, },
{ {
"content": "<uploaded_files>\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</uploaded_files>", "content": "<current_uploads>\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</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",

View File

@ -15,7 +15,7 @@
"id": "69411c14-efea-4c7f-a322-2dd541b98fda" "id": "69411c14-efea-4c7f-a322-2dd541b98fda"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",
@ -565,7 +565,7 @@
"id": "5b5a187a-5b2d-4c9e-b6f7-817fe9c12330" "id": "5b5a187a-5b2d-4c9e-b6f7-817fe9c12330"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",

View File

@ -15,7 +15,7 @@
"id": "7093f725-fdee-47b0-b135-abeaea804aff" "id": "7093f725-fdee-47b0-b135-abeaea804aff"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",

View File

@ -15,7 +15,7 @@
"id": "6409a240-5eeb-4df5-8681-4ad80a1daf7e" "id": "6409a240-5eeb-4df5-8681-4ad80a1daf7e"
}, },
{ {
"content": "<uploaded_files>\nNo files have been uploaded yet.\n</uploaded_files>", "content": "<current_uploads>\nNo files have been uploaded yet.\n</current_uploads>",
"additional_kwargs": {}, "additional_kwargs": {},
"response_metadata": {}, "response_metadata": {},
"type": "system", "type": "system",

View File

@ -430,6 +430,11 @@ function MessageContent_({
rawContent.includes("<uploaded_files>") rawContent.includes("<uploaded_files>")
) { ) {
// If the content contains an upload context tag, we return the parsed files from the content for backward compatibility. // If the content contains an upload context tag, we return the parsed files from the content for backward compatibility.
// <uploaded_files> 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 parseUploadedFiles(rawContent);
} }
return null; return null;

View File

@ -846,6 +846,18 @@ export interface FileInMessage {
* Strip backend-injected human context tags from message content. * Strip backend-injected human context tags from message content.
* Kept under its historical name because callers use it for uploaded-file * Kept under its historical name because callers use it for uploaded-file
* display cleanup. * display cleanup.
*
* Display-only backward compatibility for #4212: ``<uploaded_files>`` 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 { export function stripUploadedFilesTag(content: string): string {
return content return content
@ -862,8 +874,9 @@ export function stripUploadedFilesTag(content: string): string {
* *
* These markers are *not* user copy they come from: * These markers are *not* user copy they come from:
* *
* - ``UploadsMiddleware`` ``<current_uploads>`` (``<uploaded_files>`` * - ``UploadsMiddleware`` ``<current_uploads>`` (``<uploaded_files>`` is
* before #4174; still emitted by IM channels and present in history) * 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`` ``<slash_skill_activation>`` * - ``SkillActivationMiddleware`` ``<slash_skill_activation>``
* - ``DynamicContextMiddleware`` ``<system-reminder>`` (carrying * - ``DynamicContextMiddleware`` ``<system-reminder>`` (carrying
* ``<memory>`` / ``<current_date>`` inside) * ``<memory>`` / ``<current_date>`` inside)
@ -926,8 +939,9 @@ function parseHumanReadableSize(raw: string): number {
} }
export function parseUploadedFiles(content: string): FileInMessage[] { export function parseUploadedFiles(content: string): FileInMessage[] {
// Match the upload context block; the tag name depends on backend version // Match the upload context block. <current_uploads> is what
// (<current_uploads> since #4174, <uploaded_files> before / on IM paths). // UploadsMiddleware emits (#4174); <uploaded_files> is kept for
// display-only backward compatibility with pre-#4174 history (#4212).
const uploadedFilesRegex = const uploadedFilesRegex =
/<(current_uploads|uploaded_files)>([\s\S]*?)<\/\1>/; /<(current_uploads|uploaded_files)>([\s\S]*?)<\/\1>/;
// eslint-disable-next-line @typescript-eslint/prefer-regexp-exec // eslint-disable-next-line @typescript-eslint/prefer-regexp-exec

View File

@ -141,7 +141,8 @@ function buildJSONMessage(
): JSONExportMessage | null { ): JSONExportMessage | null {
// Run the same sanitiser the Markdown path uses so the JSON `content` // Run the same sanitiser the Markdown path uses so the JSON `content`
// field never carries inline `<think>...</think>` wrappers, content-array // field never carries inline `<think>...</think>` wrappers, content-array
// thinking blocks, `<uploaded_files>` markers, or other internal payloads. // thinking blocks, `<current_uploads>`/`<uploaded_files>` markers, or other
// internal payloads.
const content = formatMessageContent(msg); const content = formatMessageContent(msg);
const reasoning = const reasoning =
options.includeReasoning && msg.type === "ai" options.includeReasoning && msg.type === "ai"

View File

@ -584,9 +584,12 @@ describe("isHiddenFromUIMessage", () => {
}); });
describe("human message internal context stripping", () => { 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 <uploaded_files> blocks, which copy data must strip rather
// than leak as raw XML with server-side paths.
const message = { const message = {
id: "human-with-upload", id: "human-with-legacy-upload",
type: "human", type: "human",
content: content:
"<uploaded_files>\nThe following files were uploaded in this message:\n\n- paper.pdf (1.0 MB)\n Path: /mnt/user-data/uploads/paper.pdf\n</uploaded_files>\n\nSummarize this paper", "<uploaded_files>\nThe following files were uploaded in this message:\n\n- paper.pdf (1.0 MB)\n Path: /mnt/user-data/uploads/paper.pdf\n</uploaded_files>\n\nSummarize this paper",

View File

@ -321,7 +321,16 @@ test("stripLeakedSystemTags handles no tags present", () => {
expect(stripLeakedSystemTags(input)).toBe(input); expect(stripLeakedSystemTags(input)).toBe(input);
}); });
test("stripLeakedSystemTags strips <uploaded_files> tag", () => { test("stripLeakedSystemTags strips <current_uploads> tag", () => {
expect(
stripLeakedSystemTags("<current_uploads>file.pdf</current_uploads>"),
).toBe("file.pdf");
});
test("stripLeakedSystemTags strips legacy <uploaded_files> tag", () => {
// Display-only backward compatibility (#4212): pre-#4174 history still
// carries <uploaded_files> blocks; the leaked-tag stripper keeps handling
// the legacy spelling so old threads do not render raw XML.
expect( expect(
stripLeakedSystemTags("<uploaded_files>file.pdf</uploaded_files>"), stripLeakedSystemTags("<uploaded_files>file.pdf</uploaded_files>"),
).toBe("file.pdf"); ).toBe("file.pdf");

View File

@ -218,10 +218,24 @@ describe("formatThreadAsJSON", () => {
expect(raw).toContain("final visible text"); expect(raw).toContain("final visible text");
}); });
it("strips <uploaded_files> markers from content", () => { it("strips <current_uploads> markers from content", () => {
const message = human(
"real prompt\n<current_uploads>\n/mnt/user-data/uploads/secret.pdf\n</current_uploads>",
{ id: "h-clean" } as Partial<Message>,
);
const raw = formatThreadAsJSON(makeThread(), [message]);
expect(raw).not.toContain("<current_uploads>");
expect(raw).not.toContain("secret.pdf");
expect(raw).toContain("real prompt");
});
it("strips legacy <uploaded_files> markers from content", () => {
// Display-only backward compatibility (#4212): pre-#4174 history still
// carries <uploaded_files> blocks; exports must keep stripping the
// legacy spelling so server-side upload paths never leak.
const message = human( const message = human(
"real prompt\n<uploaded_files>\n/mnt/user-data/uploads/secret.pdf\n</uploaded_files>", "real prompt\n<uploaded_files>\n/mnt/user-data/uploads/secret.pdf\n</uploaded_files>",
{ id: "h-clean" } as Partial<Message>, { id: "h-legacy-clean" } as Partial<Message>,
); );
const raw = formatThreadAsJSON(makeThread(), [message]); const raw = formatThreadAsJSON(makeThread(), [message]);
expect(raw).not.toContain("<uploaded_files>"); expect(raw).not.toContain("<uploaded_files>");