fix(client): preserve existing Markdown when publishing upload companions (#7054)

* fix(client): preserve existing Markdown when publishing upload companions

* test(client): explain running-loop conversion coverage

* ci: upgrade Playwright to 1.62.1 for Ubuntu 26.04

Backport the already-merged upstream #7219 fix to unblock the older
#7054 replay workflow, which still locks Playwright 1.59.1.

(cherry picked from commit e790e7e6bab37c6e61d61f36c65f15d79a8655f8)

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
SumEn 2026-10-22 14:43:34 +08:00 committed by GitHub
parent 60940ae9c5
commit 3ccc876493
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 112 additions and 9 deletions

View File

@ -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`,保留相同过滤条件,直到末页不再

View File

@ -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

View File

@ -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

View File

@ -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