From f840e843d3e2db1485cb65ceae73a5525aa80193 Mon Sep 17 00:00:00 2001 From: lilei <93921041+xihongshichaojidan8@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:36:35 +0800 Subject: [PATCH] fix: track converted upload ownership for outlines and listings (#6101) * fix: track ownership of converted upload markdown * fix: invalidate overwritten companions and restore branch ownership * docs: explain legacy upload companion behavior on upgrade * docs: shorten upload companion guidance to fit instruction budget --------- Co-authored-by: Willem Jiang --- CHANGELOG.md | 10 ++ CHANGELOG_zh.md | 8 ++ backend/AGENTS.md | 1 + backend/README.md | 6 + backend/app/gateway/routers/threads.py | 12 ++ backend/app/gateway/upload_ingestion.py | 10 ++ backend/docs/FILE_UPLOAD.md | 25 +++-- backend/packages/harness/deerflow/client.py | 3 + .../builtins/list_uploaded_files_tool.py | 21 ++-- .../harness/deerflow/uploads/companions.py | 105 ++++++++++++++++++ .../harness/deerflow/utils/file_outline.py | 9 +- backend/tests/test_client.py | 19 ++++ .../tests/test_list_uploaded_files_tool.py | 58 ++++++++++ backend/tests/test_threads_router.py | 14 +++ .../test_uploads_middleware_core_logic.py | 5 + backend/tests/test_uploads_router.py | 2 + 16 files changed, 279 insertions(+), 29 deletions(-) create mode 100644 backend/packages/harness/deerflow/uploads/companions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a061a359ef..f82a521f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -460,6 +460,15 @@ This release closes that milestone with **301 merged pull requests**. ### Fixed +- **uploads:** Converted Markdown ownership is now recorded when a document is + converted. `list_uploaded_files` hides only verified conversion outputs, and + document outlines use only the recorded companion; a user-uploaded Markdown + file with the same stem stays visible and cannot become another document's + outline. Existing conversions have no ownership record and cannot be safely + distinguished from user-authored Markdown. After upgrading, those Markdown + files appear separately in historical listings and their source documents + have no inferred outline or preview. Re-uploading the source creates a new + verified conversion when automatic conversion is enabled. ([#6101]) - **agents:** A retried model call in plan mode no longer loses the todo completion reminder that `TodoMiddleware` had queued for it. The middleware drained the reminder inside `wrap_model_call`; because @@ -7539,6 +7548,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#6089]: https://github.com/bytedance/deer-flow/pull/6089 [#6091]: https://github.com/bytedance/deer-flow/pull/6091 [#6093]: https://github.com/bytedance/deer-flow/pull/6093 +[#6101]: https://github.com/bytedance/deer-flow/pull/6101 [#6112]: https://github.com/bytedance/deer-flow/pull/6112 [#6132]: https://github.com/bytedance/deer-flow/pull/6132 [#6134]: https://github.com/bytedance/deer-flow/pull/6134 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 2afaac5f64..acc22cabb2 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -419,6 +419,13 @@ ### 修复 +- **上传:** 文档转换时现在会记录原文件与 Markdown 的归属关系。 + `list_uploaded_files` 只隐藏归属已验证的转换文件,文档大纲也只读取 + 记录中指定的 Markdown;用户自行上传的同名文件会正常显示,不会被 + 误用作其他文档的大纲。旧版本生成的转换文件没有归属记录,无法与 + 用户自行上传的 Markdown 安全地区分。升级后,它们会作为独立文件 + 出现在历史列表中,原文档也不再从同名文件推断大纲或预览。启用 + 自动转换后重新上传原文件,可以生成有归属记录的新转换文件。([#6101]) - **智能体:** 计划模式下被重试的模型调用不再丢失 `TodoMiddleware` 已为其排队的待办完成提醒。该中间件在 `wrap_model_call` 中取出提醒;由于 `LLMErrorHandlingMiddleware` 包裹着它并通过再次调用自己的 handler 来重试, @@ -6311,6 +6318,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#6089]: https://github.com/bytedance/deer-flow/pull/6089 [#6091]: https://github.com/bytedance/deer-flow/pull/6091 [#6093]: https://github.com/bytedance/deer-flow/pull/6093 +[#6101]: https://github.com/bytedance/deer-flow/pull/6101 [#6112]: https://github.com/bytedance/deer-flow/pull/6112 [#6132]: https://github.com/bytedance/deer-flow/pull/6132 [#6134]: https://github.com/bytedance/deer-flow/pull/6134 diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 0f4f197793..bec03d7ae9 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -338,6 +338,7 @@ Outlines use ATX syntax (1–6 hashes, space/tab separator, ≤3 leading spaces) - Rejects directories before copying to keep uploads all-or-nothing - One conversion worker per request when called from an active event loop - Files stored in thread-isolated directories under the resolving user's bucket (`users/{user_id}/threads/{thread_id}/user-data/uploads`). For IM channels the owner is threaded explicitly via the `user_id=` kwarg (see IM Channels → Owner-scoped file storage); HTTP/embedded callers resolve it from `get_effective_user_id()` +- Thread ownership: `upload-companions/`. - Duplicate filenames within one request get `_N` suffixes to prevent overwrites. - Gateway HTTP uploads stage `.upload-*.part` files, hidden from upload listings, agent context, and sandbox listings/searches. After size validation, publication is atomic; staged-name cleanup logs errors and leaves leftovers for startup sweep. - Gateway HTTP upload/list/delete handlers offload filesystem work through `deerflow.utils.file_io.run_file_io`, a dedicated ContextVar-preserving file IO executor. Non-mounted sandbox uploads acquire sandboxes with `SandboxProvider.acquire_async()` and offload `read_bytes()` plus `sandbox.update_file()` together. diff --git a/backend/README.md b/backend/README.md index 6534cf4487..937d391581 100644 --- a/backend/README.md +++ b/backend/README.md @@ -147,6 +147,12 @@ FastAPI application providing REST endpoints for frontend integration: | `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail | | `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts | +Converted-upload ownership records live in each thread's `upload-companions/` +directory, outside the sandbox-mounted `user-data/` tree. Older conversions +without a record remain separate Markdown uploads and no longer provide an +inferred outline for their source document; see [file upload storage and upgrade +behavior](docs/FILE_UPLOAD.md#支持的文档格式). + ### IM Channels The IM bridge supports Feishu, Slack, and Telegram. Slack and Telegram still use the final `runs.wait()` response path, while Feishu now streams through `runs.stream(["messages-tuple", "values"])`, serializes rapid same-thread turns inside the channel manager, and updates a single in-thread card per source message in place. diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py index 155ab0d553..f9989c5540 100644 --- a/backend/app/gateway/routers/threads.py +++ b/backend/app/gateway/routers/threads.py @@ -72,6 +72,7 @@ from deerflow.runtime.runs.manager import ConflictError from deerflow.runtime.runs.worker import RUN_MESSAGE_IDS_METADATA_KEY, valid_duration_entry, valid_run_message_id_entry from deerflow.runtime.secret_context import redact_metadata_secrets from deerflow.runtime.user_context import get_effective_user_id +from deerflow.uploads.companions import register_companion, resolve_companion from deerflow.utils.file_io import run_file_io from deerflow.utils.thread_id import ThreadId, resolve_thread_id, validate_thread_id from deerflow.utils.time import coerce_iso, now_iso @@ -331,6 +332,17 @@ def _copy_branch_user_data_sync(paths: Paths, source_thread_id: str, target_thre return "not_found" shutil.copytree(source, target, ignore=_ignore_branch_user_data, dirs_exist_ok=True) + source_uploads = paths.sandbox_uploads_dir(source_thread_id, user_id=user_id) + target_uploads = paths.sandbox_uploads_dir(target_thread_id, user_id=user_id) + if source_uploads.is_dir() and target_uploads.is_dir(): + for original in source_uploads.iterdir(): + markdown = resolve_companion(original) + if markdown is None: + continue + copied_original = target_uploads / original.name + copied_markdown = target_uploads / markdown.name + if copied_original.is_file() and not copied_original.is_symlink() and copied_markdown.is_file() and not copied_markdown.is_symlink(): + register_companion(copied_original, copied_markdown) return "current_thread_best_effort" diff --git a/backend/app/gateway/upload_ingestion.py b/backend/app/gateway/upload_ingestion.py index 6d8caabfe7..37f79b2843 100644 --- a/backend/app/gateway/upload_ingestion.py +++ b/backend/app/gateway/upload_ingestion.py @@ -41,6 +41,7 @@ from typing import TYPE_CHECKING, Any from fastapi import HTTPException from deerflow.config.app_config import AppConfig +from deerflow.uploads.companions import register_companion, unregister_companion from deerflow.utils.file_io import await_drained, run_file_io if TYPE_CHECKING: @@ -154,6 +155,7 @@ class ThreadUploadIngestionService: self._auto_convert = False self._seen_filenames: set[str] = set() self._written_paths: list[Path] = [] + self._companion_pairs: list[tuple[Path, Path]] = [] self._sync_targets: list[tuple[Path, str]] = [] self._total_size = 0 @@ -378,6 +380,7 @@ class ThreadUploadIngestionService: if self._sync_to_sandbox: self._sync_targets.append((md_path, md_virtual_path)) file_info["markdown_file"] = md_path.name + self._companion_pairs.append((file_path, md_path)) file_info["markdown_path"] = str(self._uploads_dir / md_path.name) file_info["markdown_virtual_path"] = md_virtual_path file_info["markdown_artifact_url"] = uploads.upload_artifact_url(self._thread_id, md_path.name) @@ -403,6 +406,10 @@ class ThreadUploadIngestionService: if self._sync_to_sandbox and self._sandbox is not None: for file_path, virtual_path in self._sync_targets: await run_file_io(uploads._sync_upload_to_sandbox, self._sandbox, file_path, virtual_path) + # Record the final inode/version only after sandbox-readable chmod and + # any synchronization have completed; chmod changes st_ctime_ns. + for original, markdown in self._companion_pairs: + await run_file_io(register_companion, original, markdown) async def cleanup_written(self) -> None: """Remove every file this session wrote (ordinary rejected-request cleanup).""" @@ -410,6 +417,9 @@ class ThreadUploadIngestionService: return uploads = _uploads() await run_file_io(uploads._cleanup_uploaded_paths, self._written_paths) + for original, _ in self._companion_pairs: + await run_file_io(unregister_companion, original) + self._companion_pairs = [] self._written_paths = [] async def aclose(self) -> None: diff --git a/backend/docs/FILE_UPLOAD.md b/backend/docs/FILE_UPLOAD.md index 6ca03cf925..31241c1d6b 100644 --- a/backend/docs/FILE_UPLOAD.md +++ b/backend/docs/FILE_UPLOAD.md @@ -114,7 +114,9 @@ DELETE /api/threads/{thread_id}/uploads/{filename} - Excel (`.xls`, `.xlsx`) - Word (`.doc`, `.docx`) -转换后的 Markdown 文件会保存在同一目录下,文件名为原文件名 + `.md` 扩展名。 +转换后的 Markdown 文件会保存在同一目录下,通常使用原文件的主干名加 `.md`;若该名称已被占用,则追加 `_N` 后缀。上传响应中的 `markdown_file` 给出实际名称。 + +新转换会在沙箱挂载目录之外保存原文件与转换文件的归属记录。Agent 的历史文件列表只隐藏归属已验证的转换文件;文档大纲也只读取这份记录指定的 Markdown。旧版本生成的转换文件没有归属记录,升级后会作为独立 Markdown 显示,原文件也不会再从同名文件推断大纲。因为旧文件无法与用户自行上传的同名 Markdown 安全地区分,系统不会自动补建记录;需要大纲时,可在启用自动转换后重新上传原文件。 默认情况下,自动转换是关闭的,以避免在网关主机上对不受信任的 Office/PDF 上传执行解析。只有在受信任部署中明确接受此风险时,才应将 `uploads.auto_convert_documents` 设置为 `true`。 @@ -241,15 +243,16 @@ print(response.json()) ## 文件存储结构 ``` -backend/.deer-flow/threads/ -└── {thread_id}/ - └── user-data/ - └── uploads/ - ├── document.pdf # 原始文件 - ├── document.md # 转换后的 Markdown - ├── presentation.pptx - ├── presentation.md - └── ... +{DEER_FLOW_HOME}/users/{user_id}/threads/{thread_id}/ +├── upload-companions/ # 服务端归属记录,不挂载到沙箱 +│ └── <原文件名的 SHA-256>.json +└── user-data/ + └── uploads/ + ├── document.pdf # 原始文件 + ├── document.md # 转换后的 Markdown + ├── presentation.pptx + ├── presentation.md + └── ... ``` ## 限制 @@ -257,7 +260,7 @@ backend/.deer-flow/threads/ - 最大文件大小:100MB(可在 nginx.conf 中配置 `client_max_body_size`) - 文件名安全性:系统会自动验证文件路径,防止目录遍历攻击 - 删除只作用于普通文件:上传目录中的符号链接不会被跟随,删除请求按文件不存在(404)处理 -- 删除文档不会一并删除其转换生成的 Markdown:该 `.md` 的归属无法从文件名确定(同主干名的另一个文档或用户自己上传的文件都可能占用该名称),因此不再依据推测删除。它仍会出现在上传列表中,可单独删除(见 issue #5672) +- 删除文档不会一并删除其转换生成的 Markdown;转换文件仍可通过上传 API 单独删除。`list_uploaded_files` 只对有归属记录的转换文件做历史发现排除,不影响上传 API 的完整文件列表(见 issue #5672) - 上传(HTTP 与嵌入式 `DeerFlowClient`)不会写穿符号链接:目标名已是符号链接的文件会被跳过并列入 `skipped_files`,转换生成的 Markdown 也不会写入同名符号链接 - 转换读取的是本次上传写入的字节,而非落盘后的文件名:HTTP 上传在 uploads 之外的私有副本上转换,嵌入式客户端转换调用方提供的源文件,因此沙箱替换该文件名无法让宿主文件内容被转换进 uploads - 线程隔离:每个线程的上传文件相互隔离,无法跨线程访问 diff --git a/backend/packages/harness/deerflow/client.py b/backend/packages/harness/deerflow/client.py index c36f7016fb..6144944ef8 100644 --- a/backend/packages/harness/deerflow/client.py +++ b/backend/packages/harness/deerflow/client.py @@ -1734,6 +1734,7 @@ class DeerFlowClient: or its filename is unsafe or reserved for upload staging. """ validate_thread_id(thread_id) + from deerflow.uploads.companions import invalidate_overwritten_upload, register_companion from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_to_markdown # Validate all files upfront to avoid partial uploads. @@ -1779,6 +1780,7 @@ class DeerFlowClient: logger.warning("Skipping upload with unsafe destination: %s", dest_name) skipped_files.append(dest_name) continue + invalidate_overwritten_upload(dest) info: dict[str, Any] = { "filename": dest_name, @@ -1827,6 +1829,7 @@ class DeerFlowClient: md_path = None if md_path is not None: + register_companion(dest, md_path) info["markdown_file"] = md_path.name info["markdown_path"] = str(uploads_dir / md_path.name) info["markdown_virtual_path"] = upload_virtual_path(md_path.name) diff --git a/backend/packages/harness/deerflow/tools/builtins/list_uploaded_files_tool.py b/backend/packages/harness/deerflow/tools/builtins/list_uploaded_files_tool.py index b73110b858..1a080b5a18 100644 --- a/backend/packages/harness/deerflow/tools/builtins/list_uploaded_files_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/list_uploaded_files_tool.py @@ -23,6 +23,7 @@ from deerflow.agents.middlewares.input_sanitization_middleware import neutralize from deerflow.config.paths import get_paths from deerflow.runtime.user_context import get_effective_user_id from deerflow.tools.types import Runtime +from deerflow.uploads.companions import companion_names from deerflow.uploads.manager import is_upload_staging_file from deerflow.utils.file_outline import extract_outline_for_file @@ -191,14 +192,14 @@ def _list_uploaded_files_impl( outline_filenames = set(include_outline) # Collect historical files (sorted by mtime descending). - # Skip .md files that are conversion artifacts (have a same-stem non-.md sibling). + # Hide only Markdown with an explicit server-owned conversion record. candidates: list[tuple[int, Path, int]] = [] listing_metadata: list[tuple[str, int, int, int]] = [] try: - # Collect file entries once to build the name set and iterate. + # Collect file entries once, then exclude only registered companions. with os.scandir(uploads_dir) as scan: entries = [e for e in scan if e.is_file(follow_symlinks=False) and not is_upload_staging_file(e.name)] - all_names: set[str] = {e.name for e in entries} + derived_names = companion_names(uploads_dir) for entry in entries: stat = entry.stat(follow_symlinks=False) @@ -207,16 +208,8 @@ def _list_uploaded_files_impl( listing_metadata.append((entry.name, stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size)) if entry.name in current_run_filenames: continue - # Skip .md files that are conversion artifacts of another file. - # Known limitation: if a user manually uploads both report.pdf and - # report.md, the .md is hidden as a "conversion artifact". This is - # acceptable for the MVP — triggering this requires uploading files - # whose stems collide with converted documents, which is rare. - if entry.name.endswith(".md"): - stem = entry.name[:-3] # remove ".md" - non_md_siblings = {n for n in all_names if n != entry.name and Path(n).stem == stem} - if non_md_siblings: - continue + if entry.name in derived_names: + continue candidates.append((stat.st_mtime_ns, Path(entry.path), stat.st_size)) except OSError: if cursor is not None: @@ -228,7 +221,7 @@ def _list_uploaded_files_impl( # 绑定原始文件名和元数据,不把标识或路径编码到模型可见的游标中。 revision = hashlib.sha256( json.dumps( - [str(uploads_dir), user_id, thread_id, query_filter.casefold() if query_filter else None, sorted(extension_filter or ()), sorted(current_run_filenames), sorted(listing_metadata)], + [str(uploads_dir), user_id, thread_id, query_filter.casefold() if query_filter else None, sorted(extension_filter or ()), sorted(current_run_filenames), sorted(derived_names), sorted(listing_metadata)], ensure_ascii=True, separators=(",", ":"), ).encode("ascii") diff --git a/backend/packages/harness/deerflow/uploads/companions.py b/backend/packages/harness/deerflow/uploads/companions.py new file mode 100644 index 0000000000..50f02f99a9 --- /dev/null +++ b/backend/packages/harness/deerflow/uploads/companions.py @@ -0,0 +1,105 @@ +"""Server-owned ownership records for converted upload Markdown files. + +Records live beside ``user-data``, outside the sandbox mount. A matching +basename in the writable uploads directory is never evidence of ownership. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +from pathlib import Path + + +def _records_dir(uploads_dir: Path) -> Path: + # Production layout is /user-data/uploads. Tests and embedded + # callers may use a flat /uploads layout. + thread_dir = uploads_dir.parent.parent if uploads_dir.parent.name == "user-data" else uploads_dir.parent + return thread_dir / "upload-companions" + + +def _record_path(uploads_dir: Path, filename: str) -> Path: + digest = hashlib.sha256(filename.encode("utf-8")).hexdigest() + return _records_dir(uploads_dir) / f"{digest}.json" + + +def register_companion(original: Path, markdown: Path) -> None: + """Atomically associate a successfully converted file with its source.""" + if original.parent != markdown.parent or original.suffix.lower() == ".md" or markdown.suffix.lower() != ".md": + raise ValueError("Companion and source must be in the same uploads directory") + records_dir = _records_dir(original.parent) + records_dir.mkdir(parents=True, exist_ok=True) + record = _record_path(original.parent, original.name) + source_stat = original.stat(follow_symlinks=False) + markdown_stat = markdown.stat(follow_symlinks=False) + fd, staged = tempfile.mkstemp(prefix=".companion-", dir=records_dir) + try: + with os.fdopen(fd, "w", encoding="utf-8") as output: + json.dump( + { + "original": original.name, + "markdown": markdown.name, + "source_identity": [source_stat.st_dev, source_stat.st_ino, source_stat.st_size], + "markdown_identity": [markdown_stat.st_dev, markdown_stat.st_ino, markdown_stat.st_size, markdown_stat.st_ctime_ns], + }, + output, + ) + os.replace(staged, record) + finally: + if os.path.exists(staged): + os.unlink(staged) + + +def unregister_companion(original: Path) -> None: + _record_path(original.parent, original.name).unlink(missing_ok=True) + + +def invalidate_overwritten_upload(path: Path) -> None: + """Forget any relationship involving a file independently re-uploaded in place.""" + unregister_companion(path) + if path.suffix.lower() != ".md": + return + records_dir = _records_dir(path.parent) + if not records_dir.is_dir(): + return + for record in records_dir.glob("*.json"): + try: + if record.is_symlink(): + continue + if json.loads(record.read_text(encoding="utf-8")).get("markdown") == path.name: + record.unlink(missing_ok=True) + except (OSError, ValueError, AttributeError): + continue + + +def resolve_companion(original: Path) -> Path | None: + """Return only the Markdown named by a valid server-owned record.""" + if original.suffix.lower() == ".md" or not original.is_file() or original.is_symlink(): + return None + try: + record = _record_path(original.parent, original.name) + if record.is_symlink(): + return None + data = json.loads(record.read_text(encoding="utf-8")) + name = data["markdown"] + if data["original"] != original.name or not isinstance(name, str) or Path(name).name != name or not name.endswith(".md"): + return None + source_stat = original.stat(follow_symlinks=False) + if data.get("source_identity") != [source_stat.st_dev, source_stat.st_ino, source_stat.st_size]: + return None + markdown = original.parent / name + if not markdown.is_file() or markdown.is_symlink(): + return None + markdown_stat = markdown.stat(follow_symlinks=False) + if data.get("markdown_identity") != [markdown_stat.st_dev, markdown_stat.st_ino, markdown_stat.st_size, markdown_stat.st_ctime_ns]: + return None + return markdown + except (OSError, ValueError, KeyError, TypeError): + return None + + +def companion_names(uploads_dir: Path) -> set[str]: + """Names of present, explicitly owned derived Markdown files.""" + return {markdown.name for original in uploads_dir.iterdir() if original.is_file() and (markdown := resolve_companion(original)) is not None} diff --git a/backend/packages/harness/deerflow/utils/file_outline.py b/backend/packages/harness/deerflow/utils/file_outline.py index 8eda80fdc7..ae50e8420e 100644 --- a/backend/packages/harness/deerflow/utils/file_outline.py +++ b/backend/packages/harness/deerflow/utils/file_outline.py @@ -10,6 +10,8 @@ import logging import re from pathlib import Path +from deerflow.uploads.companions import resolve_companion + logger = logging.getLogger(__name__) # Regex for bold structural headings produced by pymupdf4llm when it can't @@ -175,8 +177,7 @@ def extract_outline(md_path: Path) -> list[dict]: def extract_outline_for_file(file_path: Path) -> tuple[list[dict], list[str]]: """Return the document outline and fallback preview for *file_path*. - Looks for a sibling ``.md`` file produced by the upload conversion - pipeline. + Uses a server-owned conversion record for non-Markdown uploads. Returns: (outline, preview) where: @@ -186,8 +187,8 @@ def extract_outline_for_file(file_path: Path) -> tuple[list[dict], list[str]]: anchor when outline is empty, capped at 2000 characters across all lines. Empty when outline is non-empty (no fallback needed). """ - md_path = file_path.with_suffix(".md") - if not md_path.is_file(): + md_path = file_path if file_path.suffix.lower() == ".md" else resolve_companion(file_path) + if md_path is None or not md_path.is_file(): return [], [] outline = extract_outline(md_path) diff --git a/backend/tests/test_client.py b/backend/tests/test_client.py index 11cc112106..33cb16186c 100644 --- a/backend/tests/test_client.py +++ b/backend/tests/test_client.py @@ -2953,6 +2953,25 @@ class TestUploads: assert result["files"][1]["markdown_file"] == "a_1.md" assert (uploads_dir / "a.md").read_text(encoding="utf-8") == "FROM:a.docx" assert (uploads_dir / "a_1.md").read_text(encoding="utf-8") == "FROM:a.pdf" + from deerflow.uploads.companions import resolve_companion + + assert resolve_companion(uploads_dir / "a.docx") == uploads_dir / "a.md" + assert resolve_companion(uploads_dir / "a.pdf") == uploads_dir / "a_1.md" + + authored = tmp_path / "replacement" / "a_1.md" + authored.parent.mkdir() + authored.write_text("# My notes", encoding="utf-8") # same byte length as FROM:a.pdf + with ( + patch("deerflow.client.get_uploads_dir", return_value=uploads_dir), + patch("deerflow.client.ensure_uploads_dir", return_value=uploads_dir), + ): + client.upload_files("thread-1", [authored]) + + from deerflow.utils.file_outline import extract_outline_for_file + + assert resolve_companion(uploads_dir / "a.pdf") is None + assert extract_outline_for_file(uploads_dir / "a.pdf") == ([], []) + assert extract_outline_for_file(uploads_dir / "a_1.md")[0] == [{"title": "My notes", "line": 1}] def test_upload_files_failed_conversion_releases_the_claimed_markdown_name(self, client): """A conversion that writes nothing must not reserve stem.md against a later companion. diff --git a/backend/tests/test_list_uploaded_files_tool.py b/backend/tests/test_list_uploaded_files_tool.py index 9af3dea0bc..8c0a4d4572 100644 --- a/backend/tests/test_list_uploaded_files_tool.py +++ b/backend/tests/test_list_uploaded_files_tool.py @@ -10,6 +10,7 @@ from langchain_core.messages import HumanMessage, ToolMessage from deerflow.config.paths import Paths from deerflow.tools.builtins.list_uploaded_files_tool import _format_omitted_summary, _list_uploaded_files_impl, _resolve_thread_id +from deerflow.uploads.companions import register_companion def _paths(tmp_path): @@ -31,6 +32,10 @@ def _runtime(thread_id: str = "thread-abc", state_uploaded: list[dict] | None = return rt +def _register_same_stem_companion(uploads_dir: Path, stem: str) -> None: + register_companion(uploads_dir / f"{stem}.pdf", uploads_dir / f"{stem}.md") + + # --------------------------------------------------------------------------- # _resolve_thread_id # --------------------------------------------------------------------------- @@ -179,6 +184,7 @@ class TestListUploadedFiles: uploads_dir = _uploads_dir(tmp_path) (uploads_dir / "doc.pdf").write_bytes(b"%PDF") (uploads_dir / "doc.md").write_text("# Heading 1\n\n## Heading 2\n\nBody text.\n", encoding="utf-8") + _register_same_stem_companion(uploads_dir, "doc") result = _list_uploaded_files_impl(include_outline=True, runtime=_runtime(), _paths=_paths(tmp_path)) @@ -193,6 +199,8 @@ class TestListUploadedFiles: (uploads_dir / "a.md").write_text("# A Heading\n", encoding="utf-8") (uploads_dir / "b.pdf").write_bytes(b"%PDF") (uploads_dir / "b.md").write_text("# B Heading\n", encoding="utf-8") + _register_same_stem_companion(uploads_dir, "a") + _register_same_stem_companion(uploads_dir, "b") result = _list_uploaded_files_impl(include_outline=["a.pdf"], runtime=_runtime(), _paths=_paths(tmp_path)) @@ -204,6 +212,7 @@ class TestListUploadedFiles: uploads_dir = _uploads_dir(tmp_path) (uploads_dir / "doc.pdf").write_bytes(b"%PDF") (uploads_dir / "doc.md").write_text("# Heading\n", encoding="utf-8") + _register_same_stem_companion(uploads_dir, "doc") result = _list_uploaded_files_impl(include_outline=False, runtime=_runtime(), _paths=_paths(tmp_path)) @@ -214,6 +223,7 @@ class TestListUploadedFiles: uploads_dir = _uploads_dir(tmp_path) (uploads_dir / "plain.pdf").write_bytes(b"%PDF") (uploads_dir / "plain.md").write_text("Just some text.\nNo headings.\n", encoding="utf-8") + _register_same_stem_companion(uploads_dir, "plain") result = _list_uploaded_files_impl(include_outline=True, runtime=_runtime(), _paths=_paths(tmp_path)) @@ -232,6 +242,50 @@ class TestListUploadedFiles: assert "outline" not in f assert "outline_preview" not in f + def test_same_stem_user_markdown_is_visible_and_not_used_as_pdf_outline(self, tmp_path): + uploads_dir = _uploads_dir(tmp_path) + (uploads_dir / "report.pdf").write_bytes(b"%PDF") + (uploads_dir / "report.md").write_text("# My own notes\n", encoding="utf-8") + + result = _list_uploaded_files_impl(include_outline=True, runtime=_runtime(), _paths=_paths(tmp_path)) + + files = {f["filename"]: f for f in result["files"]} + assert set(files) == {"report.pdf", "report.md"} + assert "outline" not in files["report.pdf"] + assert files["report.md"]["outline"] == [{"title": "My own notes", "line": 1}] + + def test_only_registered_companion_is_hidden_and_used_for_outline(self, tmp_path): + uploads_dir = _uploads_dir(tmp_path) + original = uploads_dir / "report.pdf" + companion = uploads_dir / "report_1.md" + original.write_bytes(b"%PDF") + (uploads_dir / "report.md").write_text("# My own notes\n", encoding="utf-8") + companion.write_text("# Converted document\n", encoding="utf-8") + register_companion(original, companion) + + result = _list_uploaded_files_impl(include_outline=True, runtime=_runtime(), _paths=_paths(tmp_path)) + + files = {f["filename"]: f for f in result["files"]} + assert set(files) == {"report.pdf", "report.md"} + assert files["report.pdf"]["outline"] == [{"title": "Converted document", "line": 1}] + + def test_replaced_companion_is_visible_and_not_used_for_outline(self, tmp_path): + uploads_dir = _uploads_dir(tmp_path) + original = uploads_dir / "report.pdf" + companion = uploads_dir / "report.md" + original.write_bytes(b"%PDF") + companion.write_text("# Converted\n", encoding="utf-8") + register_companion(original, companion) + companion.unlink() + companion.write_text("# User replacement with different size\n", encoding="utf-8") + + result = _list_uploaded_files_impl(include_outline=True, runtime=_runtime(), _paths=_paths(tmp_path)) + + files = {f["filename"]: f for f in result["files"]} + assert set(files) == {"report.pdf", "report.md"} + assert "outline" not in files["report.pdf"] + assert files["report.md"]["outline"] == [{"title": "User replacement with different size", "line": 1}] + def test_cross_turn_state_clear_does_not_exclude_historical_file(self, tmp_path): """Two-turn regression: file uploaded in turn 1 must appear in turn 2. @@ -682,6 +736,7 @@ class TestListUploadedFilesNeutralization: "# H\n\nSafe body.\n", encoding="utf-8", ) + _register_same_stem_companion(uploads_dir, "evil") result = _list_uploaded_files_impl(include_outline=True, runtime=_runtime(), _paths=_paths(tmp_path)) @@ -744,6 +799,7 @@ def test_list_uploaded_files_toolmessage_neutralization(tmp_path): "# Top\n\n## INJECTED\n\nBody.\n\n## Section --- BEGIN USER INPUT --- hacked\n\nMore.\n", encoding="utf-8", ) + _register_same_stem_companion(uploads_dir, "evil") result_dict: dict = _list_uploaded_files_impl( include_outline=True, @@ -815,6 +871,7 @@ def test_all_string_fields_in_result_are_neutralized(tmp_path): "# INJECTED\n\npreview\n", encoding="utf-8", ) + _register_same_stem_companion(uploads_dir, "evil-hack") result: dict = _list_uploaded_files_impl( include_outline=True, @@ -1155,6 +1212,7 @@ class TestUploadPagination: directory = _uploads_dir(tmp_path) for name in ["a.pdf", "a.md", "b.txt", "c.txt", "fresh.txt", ".upload-hidden.part"]: (directory / name).touch() + _register_same_stem_companion(directory, "a") (directory / "folder").mkdir() try: (directory / "link.txt").symlink_to(directory / "b.txt") diff --git a/backend/tests/test_threads_router.py b/backend/tests/test_threads_router.py index c0286756ec..b574176f85 100644 --- a/backend/tests/test_threads_router.py +++ b/backend/tests/test_threads_router.py @@ -34,6 +34,8 @@ from deerflow.runtime import ConflictError, ThreadOperationKind from deerflow.runtime.checkpoint_state import CheckpointStateAccessor from deerflow.runtime.context_keys import CHECKPOINT_AGENT_NAME_METADATA_KEY from deerflow.runtime.user_context import reset_current_user, set_current_user +from deerflow.uploads.companions import companion_names, register_companion, resolve_companion +from deerflow.utils.file_outline import extract_outline_for_file _ISO_TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}") @@ -3896,6 +3898,13 @@ def test_branch_thread_best_effort_copies_current_workspace(tmp_path) -> None: source_uploads.mkdir(parents=True, exist_ok=True) (source_outputs / "result.txt").write_text("answer", encoding="utf-8") (source_uploads / ".upload-stale.part").write_text("partial", encoding="utf-8") + original = source_uploads / "report.pdf" + markdown = source_uploads / "report.md" + original.write_bytes(b"%PDF") + markdown.write_text("# Converted report\n", encoding="utf-8") + register_companion(original, markdown) + (source_uploads / "unrelated.pdf").write_bytes(b"%PDF") + (source_uploads / "unrelated.md").write_text("# User notes\n", encoding="utf-8") human = HumanMessage(id="human-file", content="Make a file") ai = AIMessage(id="ai-file", content="Done") @@ -3928,6 +3937,11 @@ def test_branch_thread_best_effort_copies_current_workspace(tmp_path) -> None: assert target_user_data.exists() assert (target_user_data / "outputs" / "result.txt").read_text(encoding="utf-8") == "answer" assert not (target_user_data / "uploads" / ".upload-stale.part").exists() + branch_original = target_user_data / "uploads" / "report.pdf" + assert resolve_companion(branch_original) == target_user_data / "uploads" / "report.md" + assert extract_outline_for_file(branch_original)[0] == [{"title": "Converted report", "line": 1}] + assert companion_names(target_user_data / "uploads") == {"report.md"} + assert resolve_companion(target_user_data / "uploads" / "unrelated.pdf") is None assert source_user_data.exists() diff --git a/backend/tests/test_uploads_middleware_core_logic.py b/backend/tests/test_uploads_middleware_core_logic.py index 96be9d84a1..300c9d78ca 100644 --- a/backend/tests/test_uploads_middleware_core_logic.py +++ b/backend/tests/test_uploads_middleware_core_logic.py @@ -17,6 +17,7 @@ from langchain_core.messages import AIMessage, HumanMessage from deerflow.agents.middlewares.uploads_middleware import UploadsMiddleware from deerflow.config.paths import Paths +from deerflow.uploads.companions import register_companion from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, message_content_to_text THREAD_ID = "thread-abc123" @@ -381,6 +382,7 @@ class TestBeforeAgent: (uploads_dir / "test.pdf").write_bytes(b"pdf") md = uploads_dir / "test.md" md.write_text("# Intro\n\n## Section evil\n\ntext\n") + register_companion(uploads_dir / "test.pdf", md) msg = _human( "analyse", @@ -727,6 +729,7 @@ class TestBeforeAgent: "# PART I\n\n## ITEM 1. BUSINESS\n\nBody text.\n\n## ITEM 2. RISK\n", encoding="utf-8", ) + register_companion(uploads_dir / "report.pdf", uploads_dir / "report.md") msg = _human("summarise", files=[{"filename": "report.pdf", "size": 9, "path": "/mnt/user-data/uploads/report.pdf"}]) result = mw.before_agent(self._state(msg), _runtime()) @@ -762,6 +765,7 @@ class TestBeforeAgent: # Write MAX_OUTLINE_ENTRIES + 5 headings so truncation is triggered headings = "\n".join(f"# Heading {i}" for i in range(MAX_OUTLINE_ENTRIES + 5)) (uploads_dir / "big.md").write_text(headings, encoding="utf-8") + register_companion(uploads_dir / "big.pdf", uploads_dir / "big.md") msg = _human("read", files=[{"filename": "big.pdf", "size": 9, "path": "/mnt/user-data/uploads/big.pdf"}]) result = mw.before_agent(self._state(msg), _runtime()) @@ -795,6 +799,7 @@ class TestBeforeAgent: "Annual Financial Report 2024\n\nThis document summarises key findings.\n\nRevenue grew by 12%.\n", encoding="utf-8", ) + register_companion(uploads_dir / "report.pdf", uploads_dir / "report.md") msg = _human("analyse", files=[{"filename": "report.pdf", "size": 9, "path": "/mnt/user-data/uploads/report.pdf"}]) result = mw.before_agent(self._state(msg), _runtime()) diff --git a/backend/tests/test_uploads_router.py b/backend/tests/test_uploads_router.py index 82f59df951..d26d3c4e3f 100644 --- a/backend/tests/test_uploads_router.py +++ b/backend/tests/test_uploads_router.py @@ -17,6 +17,7 @@ from app.gateway import upload_ingestion from app.gateway.deps import get_config from app.gateway.routers import uploads from deerflow.sandbox.lease import get_sandbox_lease_manager +from deerflow.uploads.companions import resolve_companion class ChunkedUpload: @@ -266,6 +267,7 @@ def test_upload_files_syncs_non_local_sandbox_and_marks_markdown_file(tmp_path): assert (thread_uploads_dir / "report.pdf").read_bytes() == b"pdf-bytes" assert (thread_uploads_dir / "report.md").read_text(encoding="utf-8") == "converted" + assert resolve_companion(thread_uploads_dir / "report.pdf") == thread_uploads_dir / "report.md" sandbox.update_file.assert_any_call("/mnt/user-data/uploads/report.pdf", b"pdf-bytes") sandbox.update_file.assert_any_call("/mnt/user-data/uploads/report.md", b"converted")