diff --git a/README.md b/README.md index 499af9570e..c0bbc452b6 100644 --- a/README.md +++ b/README.md @@ -2456,7 +2456,7 @@ Content-less sub-agent final messages report `No response generated` instead of An ordinary `task` also receives a defensive snapshot of the dispatching run's current uploads. This lets eligible sub-agents use `list_uploaded_files` to find earlier-turn files without returning same-turn attachments as historical. Delayed or recovered `batch_task` workers leave this tool disabled because they have no valid turn-local upload boundary. -Embedded Python uploads use the same captured content for the saved document and its Markdown conversion, even if the caller's source changes during upload. +Embedded Python uploads use the same captured content for the saved document and its Markdown conversion, even if the caller's source changes during upload. Converted Markdown receives a free filename with a numeric suffix on collision, preserving user-authored Markdown and companions from earlier uploads. The upload response reports the actual companion filename. 历史上传发现支持稳定续页:`list_uploaded_files` 默认每页 20 项、最多 100 项, 将返回的 `next_cursor` 作为下一次调用的 `cursor`,保留相同过滤条件,直到末页不再 diff --git a/backend/packages/harness/deerflow/client.py b/backend/packages/harness/deerflow/client.py index 2f570c43d7..0cfcdeb343 100644 --- a/backend/packages/harness/deerflow/client.py +++ b/backend/packages/harness/deerflow/client.py @@ -1873,11 +1873,10 @@ class DeerFlowClient: # copy, not write_bytes: the companion keeps the # converter's permissions, so a sandbox running as # another uid can still read it. - if unique_md_name in existing_names: - md_path = copy_upload_file_no_symlink(uploads_dir, unique_md_name, converted) - else: - md_path = publish_upload_copy(uploads_dir, unique_md_name, converted, claim_seed=provisional_md_name, seen_names=seen_names) - unique_md_name = md_path.name + # Existing Markdown may be user-authored or owned + # by another document from an earlier upload call. + md_path = publish_upload_copy(uploads_dir, unique_md_name, converted, claim_seed=provisional_md_name, seen_names=seen_names) + unique_md_name = md_path.name except UnsafeUploadPathError: logger.warning("Skipping markdown companion with unsafe destination: %s", unique_md_name) md_path = None diff --git a/backend/packages/harness/deerflow/uploads/AGENTS.md b/backend/packages/harness/deerflow/uploads/AGENTS.md index f1d055a12b..0885ed71ee 100644 --- a/backend/packages/harness/deerflow/uploads/AGENTS.md +++ b/backend/packages/harness/deerflow/uploads/AGENTS.md @@ -19,9 +19,11 @@ metadata; every attempt reads the held private capture. Replacement copies share this capture helper. Never copy snapshot permission bits onto the saved upload. Close the caller's descriptor after capture and finish conversion workers before removing captures. Never reopen the caller/sandbox upload path for conversion. -Names already present in the call's initial regular-file snapshot retain -replacement behavior; these replacements are not serialized. Apply source -timestamps after new-file publication so cleanup cannot sweep an in-flight copy +Original upload names already present in the call's initial regular-file snapshot +retain replacement behavior; these replacements are not serialized. Converted +Markdown always uses no-replace publication, including across upload calls, and +registers the actual saved name. An existing basename does not establish companion +ownership. Apply source timestamps after new-file publication so cleanup cannot sweep an in-flight copy of an old source. New final names must expose complete copies. No-replace publication binds pre-link staging and post-link destination checks to diff --git a/backend/tests/test_client_upload_companion_collisions.py b/backend/tests/test_client_upload_companion_collisions.py new file mode 100644 index 0000000000..3e2657b12e --- /dev/null +++ b/backend/tests/test_client_upload_companion_collisions.py @@ -0,0 +1,102 @@ +"""Converted uploads must preserve Markdown from earlier upload calls.""" + +import asyncio +from pathlib import Path + +import pytest +from openpyxl import Workbook +from pptx import Presentation + +from deerflow.client import DeerFlowClient +from deerflow.uploads.companions import resolve_companion + + +def _document(path: Path, content: str) -> None: + if path.suffix == ".md": + path.write_text(content, encoding="utf-8") + elif path.suffix == ".xlsx": + book = Workbook() + book.active["A1"] = content + book.save(path) + book.close() + else: + slides = Presentation() + slide = slides.slides.add_slide(slides.slide_layouts[1]) + slide.shapes.title.text = content + slides.save(path) + + +@pytest.fixture +def upload_case(tmp_path, monkeypatch): + import deerflow.client as client_module + + uploads = tmp_path / "thread/user-data/uploads" + uploads.mkdir(parents=True) + monkeypatch.setattr(client_module, "ensure_uploads_dir", lambda *args, **kwargs: uploads) + return DeerFlowClient.__new__(DeerFlowClient), uploads + + +@pytest.mark.parametrize("in_loop", [False, True]) +@pytest.mark.parametrize("first_ext,second_ext", [(".md", ".xlsx"), (".md", ".pptx"), (".xlsx", ".pptx"), (".pptx", ".xlsx")]) +def test_separate_uploads_preserve_existing_markdown(upload_case, tmp_path, in_loop, first_ext, second_ext): + client, uploads = upload_case + first = tmp_path / f"report{first_ext}" + second = tmp_path / f"report{second_ext}" + _document(first, "KEEP FIRST DOCUMENT") + _document(second, "SECOND DOCUMENT") + + def upload(source): + if not in_loop: + return client.upload_files("companion-thread", [source]) + + # Run the synchronous upload inside an active event loop to exercise conversion_pool. + async def run(): + return client.upload_files("companion-thread", [source]) + + return asyncio.run(run()) + + first_response = upload(first) + original_markdown = uploads / "report.md" + original_bytes = original_markdown.read_bytes() + second_response = upload(second) + + assert first_response["success"] and second_response["success"] + assert original_markdown.read_bytes() == original_bytes + assert "KEEP FIRST DOCUMENT" in original_markdown.read_text(encoding="utf-8") + info = second_response["files"][0] + assert info["filename"] == second.name + assert info["markdown_file"] == "report_1.md" + companion = uploads / info["markdown_file"] + assert "SECOND DOCUMENT" in companion.read_text(encoding="utf-8") + assert resolve_companion(uploads / second.name) == companion + assert Path(info["markdown_path"]) == companion + assert info["markdown_virtual_path"].endswith("/report_1.md") + assert info["markdown_artifact_url"].endswith("/report_1.md") + assert (uploads / first.name).read_bytes() == first.read_bytes() + assert (uploads / second.name).read_bytes() == second.read_bytes() + if first_ext != ".md": + assert resolve_companion(uploads / first.name) == original_markdown + + +def test_reupload_replaces_original_but_preserves_occupied_companion_names(upload_case, tmp_path): + client, uploads = upload_case + source = tmp_path / "report.xlsx" + _document(source, "FIRST DOCUMENT") + client.upload_files("companion-thread", [source]) + first_markdown = (uploads / "report.md").read_bytes() + occupied = uploads / "report_1.md" + occupied.write_text("INDEPENDENT NOTES", encoding="utf-8") + + _document(source, "REPLACEMENT DOCUMENT") + response = client.upload_files("companion-thread", [source]) + + assert response["success"] + info = response["files"][0] + assert info["filename"] == "report.xlsx" + assert (uploads / source.name).read_bytes() == source.read_bytes() + assert info["markdown_file"] == "report_2.md" + assert (uploads / "report.md").read_bytes() == first_markdown + assert occupied.read_text(encoding="utf-8") == "INDEPENDENT NOTES" + companion = uploads / "report_2.md" + assert "REPLACEMENT DOCUMENT" in companion.read_text(encoding="utf-8") + assert resolve_companion(uploads / source.name) == companion