deer-flow/backend/tests/test_workspace_changes.py
TAI-YUAN GUO f5907405a0
fix(workspace): don't infer created or deleted files from truncated snapshots (#7172)
* fix(workspace): don't infer created or deleted files from truncated snapshots

A bounded workspace scan stops at max_scanned_files and marks the snapshot
truncated. Absence from such a snapshot is unknown, so creation is only
inferred from a complete before snapshot and deletion only from a complete
after snapshot. get_changed_paths() is the single filter for the summary,
text capture and output-delivery detection.

The scanner now walks subdirectories in sorted order so the truncation
boundary is reproducible across the before and after scans, and the docs
note that the budget is consumed root by root (workspace before outputs).

Supersedes #5231.

Co-authored-by: liutian <91778998+ImcLiuQian@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(runtime): record truncated delivery verification skips

Record verification_skipped=truncated_pre_run_snapshot in run.delivery
receipts when a truncated pre-run scan leaves no provable output changes.
Carry the snapshot into both normal and exceptional receipt finalization.

Keep observed-output verification and terminal statuses unchanged. Cover
success, failure, cancellation and capture failures through the real worker,
and document the per-run receipt marker.

---------

Co-authored-by: liutian <91778998+ImcLiuQian@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-22 16:52:53 +08:00

942 lines
34 KiB
Python

from __future__ import annotations
import os
from pathlib import Path
import pytest
import deerflow.workspace_changes.scanner as scanner
from deerflow.config.paths import Paths
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.runs.manager import RunManager
from deerflow.runtime.runs.worker import RunContext, run_agent
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.workspace_changes import (
WorkspaceChangeLimits,
WorkspaceRoot,
capture_workspace_snapshot,
compare_snapshots,
get_changed_output_paths,
get_changed_paths,
record_workspace_changes,
scan_workspace_roots,
)
from deerflow.workspace_changes.api import get_workspace_changes_response
from deerflow.workspace_changes.scanner import (
SAMPLE_BYTES,
_normalize_symlink_target,
is_sensitive_workspace_path,
)
def _roots(tmp_path):
workspace = tmp_path / "workspace"
outputs = tmp_path / "outputs"
workspace.mkdir()
outputs.mkdir()
return [
WorkspaceRoot(
name="workspace",
host_path=workspace,
virtual_prefix="/mnt/user-data/workspace",
),
WorkspaceRoot(
name="outputs",
host_path=outputs,
virtual_prefix="/mnt/user-data/outputs",
),
]
def test_compare_snapshots_reports_text_file_changes(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
outputs = roots[1].host_path
(workspace / "draft.md").write_text("alpha\nbeta\n", encoding="utf-8")
(workspace / "old.txt").write_text("remove me\n", encoding="utf-8")
before = scan_workspace_roots(roots)
(workspace / "draft.md").write_text("alpha\ngamma\n", encoding="utf-8")
(workspace / "old.txt").unlink()
(outputs / "report.md").write_text("# Report\n\nReady\n", encoding="utf-8")
after = scan_workspace_roots(roots)
result = compare_snapshots(before, after)
assert result.summary.created == 1
assert result.summary.modified == 1
assert result.summary.deleted == 1
assert result.summary.additions >= 3
assert result.summary.deletions >= 2
changes = {change.path: change for change in result.files}
assert changes["/mnt/user-data/workspace/draft.md"].status == "modified"
assert "-beta" in changes["/mnt/user-data/workspace/draft.md"].diff
assert "+gamma" in changes["/mnt/user-data/workspace/draft.md"].diff
assert changes["/mnt/user-data/outputs/report.md"].status == "created"
assert changes["/mnt/user-data/workspace/old.txt"].status == "deleted"
def test_cached_crlf_text_round_trips_byte_exactly_into_diffs(tmp_path):
"""The text cache is published and read back byte-exactly on every host.
The pre-run snapshot carries its text through ``_publish_text_atomically``
(write) and ``_snapshot_text`` (read) while the post-run snapshot carries
text inline, exactly like ``record_workspace_changes``. Host newline
translation on either cache side would corrupt the served text and the
built diff (a spurious blank line per CRLF line on native Windows).
"""
roots = _roots(tmp_path)
(roots[0].host_path / "notes.md").write_bytes(b"first\r\nsecond\r\n")
before_cache = tmp_path / "cache-before"
before = scan_workspace_roots(roots, text_cache_dir=before_cache)
(roots[0].host_path / "notes.md").write_bytes(b"first\r\nthird\r\n")
after = scan_workspace_roots(roots)
assert before.files["/mnt/user-data/workspace/notes.md"].text_path is not None
result = compare_snapshots(before, after)
change = {c.path: c for c in result.files}["/mnt/user-data/workspace/notes.md"]
assert "-second" in change.diff
assert "+third" in change.diff
# Pin the line counts: a host that translates newlines on the cache write
# and re-translates on read rounds the cached text up to "first\n\nsecond\n"
# (one spurious blank line per CRLF line), which shows up here as an extra
# deletion line. "-second"/"+third" alone stay present under that bug, so
# only the counts catch it — on any host, not just native Windows.
assert (change.additions, change.deletions) == (1, 1), change.diff
@pytest.mark.parametrize("include_text", [False, True])
@pytest.mark.parametrize("size", [16, SAMPLE_BYTES + 1])
def test_scan_keeps_other_files_when_a_file_disappears_before_hashing(tmp_path, monkeypatch, include_text, size):
roots = _roots(tmp_path)
workspace, outputs = [root.host_path for root in roots]
# Byte-pinned fixtures (tests/AGENTS.md): a text-mode write translates the
# LF byte into CRLF on native Windows, and the compared snapshot text
# would then round-trip as "report\r\n" instead of "report\n".
(workspace / "a-kept.txt").write_bytes(b"kept\n")
vanished = workspace / "scratch.txt"
vanished.write_text("x" * size, encoding="utf-8")
(outputs / "report.md").write_bytes(b"report\n")
real_hash = scanner._sha256_file
removed = []
def remove_before_hash(path):
if path == vanished:
path.unlink()
removed.append(path)
return real_hash(path)
monkeypatch.setattr(scanner, "_sha256_file", remove_before_hash)
snapshot = scan_workspace_roots(roots, include_text=include_text)
assert removed == [vanished]
assert set(snapshot.files) == {"/mnt/user-data/workspace/a-kept.txt", "/mnt/user-data/outputs/report.md"}
assert snapshot.files["/mnt/user-data/outputs/report.md"].sha256 is not None
assert snapshot.files["/mnt/user-data/outputs/report.md"].text == ("report\n" if include_text else None)
@pytest.mark.parametrize("error", [PermissionError("denied"), OSError("disk read failed")])
def test_scan_does_not_hide_operational_hash_errors(tmp_path, monkeypatch, error):
roots = _roots(tmp_path)
(roots[0].host_path / "report.md").write_text("report\n", encoding="utf-8")
def fail_hash(path):
raise error
monkeypatch.setattr(scanner, "_sha256_file", fail_hash)
with pytest.raises(type(error), match=str(error)):
scan_workspace_roots(roots)
def test_get_changed_output_paths_returns_only_created_or_modified_regular_outputs(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
outputs = roots[1].host_path
(workspace / "draft.md").write_text("before", encoding="utf-8")
(outputs / "existing.md").write_text("before", encoding="utf-8")
(outputs / "deleted.md").write_text("before", encoding="utf-8")
before = scan_workspace_roots(roots)
(workspace / "draft.md").write_text("after", encoding="utf-8")
(outputs / "existing.md").write_text("after", encoding="utf-8")
(outputs / "created.md").write_text("new", encoding="utf-8")
(outputs / "deleted.md").unlink()
after = scan_workspace_roots(roots)
assert get_changed_output_paths(before, after) == [
"/mnt/user-data/outputs/created.md",
"/mnt/user-data/outputs/existing.md",
]
@pytest.mark.parametrize(
("before_contents", "after_contents", "expected_statuses"),
[
pytest.param(
{"b.txt": "before", "c.txt": "unchanged"},
{"a.txt": "new", "b.txt": "before", "c.txt": "unchanged"},
{"a.txt": "created"},
id="insertion-pushes-existing-file-out-of-scan",
),
pytest.param(
{"a.txt": "removed", "b.txt": "before", "c.txt": "unchanged"},
{"b.txt": "before", "c.txt": "unchanged"},
{"a.txt": "deleted"},
id="deletion-brings-existing-file-into-scan",
),
pytest.param(
{"a.txt": "removed", "b.txt": "before", "c.txt": "unchanged"},
{"b.txt": "after", "c.txt": "unchanged", "d.txt": "new"},
{"b.txt": "modified"},
id="both-truncated-preserve-observed-modifications",
),
],
)
def test_workspace_changes_do_not_infer_absence_from_truncated_snapshots(tmp_path, before_contents, after_contents, expected_statuses):
roots = _roots(tmp_path)
outputs = roots[1].host_path
limits = WorkspaceChangeLimits(max_scanned_files=2)
for name, content in before_contents.items():
(outputs / name).write_text(content, encoding="utf-8")
before = scan_workspace_roots(roots, limits=limits)
for name in before_contents.keys() - after_contents.keys():
(outputs / name).unlink()
for name, content in after_contents.items():
(outputs / name).write_text(content, encoding="utf-8")
after = scan_workspace_roots(roots, limits=limits)
result = compare_snapshots(before, after, limits=limits)
expected = {f"/mnt/user-data/outputs/{name}": status for name, status in expected_statuses.items()}
assert {change.path: change.status for change in result.files} == expected
assert result.summary.created == list(expected.values()).count("created")
assert result.summary.modified == list(expected.values()).count("modified")
assert result.summary.deleted == list(expected.values()).count("deleted")
assert result.summary.truncated is True
assert get_changed_paths(before, after) == set(expected)
assert get_changed_output_paths(before, after) == sorted(path for path, status in expected.items() if status != "deleted")
def test_compare_snapshots_treats_utf16_markdown_as_text(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
before = scan_workspace_roots(roots)
(workspace / "guide.md").write_bytes("# 标题\n\nhello\n".encode("utf-16"))
after = scan_workspace_roots(roots)
result = compare_snapshots(before, after)
change = result.files[0]
assert change.path == "/mnt/user-data/workspace/guide.md"
assert change.binary is False
assert change.diff_unavailable_reason is None
assert "+# 标题" in change.diff
def test_compare_snapshots_reads_cached_utf16_markdown_baseline(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
cache_dir = tmp_path / "cache"
(workspace / "guide.md").write_bytes("# 标题\n\nhello\n".encode("utf-16"))
before = scan_workspace_roots(roots, text_cache_dir=cache_dir)
(workspace / "guide.md").write_bytes("# 标题\n\nupdated\n".encode("utf-16"))
after = scan_workspace_roots(roots)
result = compare_snapshots(before, after)
change = result.files[0]
assert change.binary is False
assert change.diff_unavailable_reason is None
assert "-hello" in change.diff
assert "+updated" in change.diff
def test_compare_snapshots_treats_utf8_markdown_crossing_sample_boundary_as_text(
tmp_path,
):
roots = _roots(tmp_path)
workspace = roots[0].host_path
before = scan_workspace_roots(roots)
content = ("a" * (SAMPLE_BYTES - 1)) + "你" + "\nrest\n"
(workspace / "guide.md").write_text(content, encoding="utf-8")
after = scan_workspace_roots(roots)
result = compare_snapshots(before, after)
change = result.files[0]
assert change.path == "/mnt/user-data/workspace/guide.md"
assert change.binary is False
assert "你" in change.diff
assert "+rest" in change.diff
def test_compare_snapshots_strips_utf8_bom(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
before = scan_workspace_roots(roots)
content = "# 标题\n\nhello\n".encode()
(workspace / "guide.md").write_bytes(b"\xef\xbb\xbf" + content)
after = scan_workspace_roots(roots)
result = compare_snapshots(before, after)
change = result.files[0]
assert change.path == "/mnt/user-data/workspace/guide.md"
assert change.binary is False
assert change.diff_unavailable_reason is None
assert "\ufeff" not in change.diff
assert "+# 标题" in change.diff
def test_compare_snapshots_keeps_nul_bytes_classified_as_binary(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
before = scan_workspace_roots(roots)
(workspace / "guide.md").write_bytes(b"\x00\x01\x02binary\n")
after = scan_workspace_roots(roots)
result = compare_snapshots(before, after)
change = result.files[0]
assert change.path == "/mnt/user-data/workspace/guide.md"
assert change.binary is True
assert change.diff == ""
assert change.diff_unavailable_reason == "binary"
def test_count_diff_lines_ignores_only_real_headers():
from deerflow.workspace_changes.diff import _count_diff_lines
lines = [
"--- a/mnt/user-data/workspace/file.txt",
"+++ b/mnt/user-data/workspace/file.txt",
"@@ -1,2 +1,2 @@",
"-old",
"+new",
# Content lines that happen to start with +++/--- must still count.
"+++added",
"---removed",
]
additions, deletions = _count_diff_lines(lines)
assert additions == 2
assert deletions == 2
def test_count_diff_lines_counts_content_starting_with_dashes_or_pluses():
"""Hunk-body lines whose content starts with '-- '/'++ ' must be counted.
difflib prefixes a deleted line "-- get users" to "--- get users"; the old
prefix skip mistook that for a file header and dropped it, undercounting
deletions in the user-visible +N/-M summary.
"""
import difflib
from deerflow.workspace_changes.diff import _count_diff_lines
lines = list(
difflib.unified_diff(
["SELECT 1", "-- get users"],
["SELECT 1", "SELECT 2"],
fromfile="a/x.sql",
tofile="b/x.sql",
lineterm="",
)
)
additions, deletions = _count_diff_lines(lines)
assert additions == 1
assert deletions == 1
def test_scan_workspace_roots_walks_subdirectories_in_sorted_order(tmp_path, monkeypatch):
# The truncation boundary must not depend on the filesystem's directory order.
roots = _roots(tmp_path)
workspace = roots[0].host_path
for name in ("a", "b"):
(workspace / name).mkdir()
(workspace / name / "file.txt").write_text(name, encoding="utf-8")
real_walk = os.walk
def reversed_walk(top, *args, **kwargs):
for dirpath, dirnames, filenames in real_walk(top, *args, **kwargs):
dirnames.sort(reverse=True)
yield dirpath, dirnames, filenames
monkeypatch.setattr(scanner.os, "walk", reversed_walk)
snapshot = scan_workspace_roots(roots, limits=WorkspaceChangeLimits(max_scanned_files=1))
assert snapshot.truncated is True
assert list(snapshot.files) == ["/mnt/user-data/workspace/a/file.txt"]
def test_scan_workspace_roots_skips_excluded_directories(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
(workspace / "visible.txt").write_text("visible", encoding="utf-8")
(workspace / "node_modules").mkdir()
(workspace / "node_modules" / "ignored.js").write_text(
"ignored",
encoding="utf-8",
)
snapshot = scan_workspace_roots(roots)
assert "/mnt/user-data/workspace/visible.txt" in snapshot.files
assert "/mnt/user-data/workspace/node_modules/ignored.js" not in snapshot.files
def test_scan_workspace_roots_skips_stdio_mcp_temp_files(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
(workspace / "report.md").write_text("keep", encoding="utf-8")
mcp_tmp = workspace / ".mcp" / "tmp"
mcp_tmp.mkdir(parents=True)
(mcp_tmp / "debug.json").write_text("internal", encoding="utf-8")
# `.mcp` is excluded by directory name at any depth, matching the other
# entries in EXCLUDED_DIR_NAMES and staying robust if a server ever
# creates a relative `.mcp` from a cwd below the workspace root.
nested_mcp = workspace / "project" / ".mcp"
nested_mcp.mkdir(parents=True)
(nested_mcp / "nested.json").write_text("internal", encoding="utf-8")
snapshot = scan_workspace_roots(roots)
assert "/mnt/user-data/workspace/report.md" in snapshot.files
assert "/mnt/user-data/workspace/.mcp/tmp/debug.json" not in snapshot.files
assert "/mnt/user-data/workspace/project/.mcp/nested.json" not in snapshot.files
def test_scan_workspace_roots_skips_browser_frames(tmp_path):
roots = _roots(tmp_path)
outputs = roots[1].host_path
(outputs / "report.md").write_text("keep", encoding="utf-8")
frames = outputs / ".browser-frames"
frames.mkdir()
(frames / "browser-navigate-1.png").write_bytes(b"\x89PNG\r\n\x1a\nshot")
snapshot = scan_workspace_roots(roots)
assert "/mnt/user-data/outputs/report.md" in snapshot.files
assert "/mnt/user-data/outputs/.browser-frames/browser-navigate-1.png" not in snapshot.files
def test_scan_workspace_roots_skips_externalized_tool_results(tmp_path):
roots = _roots(tmp_path)
outputs = roots[1].host_path
(outputs / "report.md").write_text("keep", encoding="utf-8")
tool_results = outputs / ".tool-results"
tool_results.mkdir()
(tool_results / "bash-abcdef123456.log").write_text("oversized tool output", encoding="utf-8")
snapshot = scan_workspace_roots(roots)
assert "/mnt/user-data/outputs/report.md" in snapshot.files
assert "/mnt/user-data/outputs/.tool-results/bash-abcdef123456.log" not in snapshot.files
def test_scan_workspace_roots_skips_extra_excluded_dir_names(tmp_path):
roots = _roots(tmp_path)
outputs = roots[1].host_path
(outputs / "report.md").write_text("keep", encoding="utf-8")
custom = outputs / "custom-tool-results"
custom.mkdir()
(custom / "bash-abcdef123456.log").write_text("oversized tool output", encoding="utf-8")
snapshot = scan_workspace_roots(roots, extra_excluded_dir_names=frozenset({"custom-tool-results"}))
assert "/mnt/user-data/outputs/report.md" in snapshot.files
assert "/mnt/user-data/outputs/custom-tool-results/bash-abcdef123456.log" not in snapshot.files
def test_get_changed_output_paths_ignores_externalized_tool_results(tmp_path):
roots = _roots(tmp_path)
outputs = roots[1].host_path
before = scan_workspace_roots(roots)
tool_results = outputs / ".tool-results"
tool_results.mkdir()
(tool_results / "web_fetch-abcdef123456.log").write_text("x" * 20000, encoding="utf-8")
(outputs / "report.md").write_text("deliverable", encoding="utf-8")
after = scan_workspace_roots(roots)
assert get_changed_output_paths(before, after) == ["/mnt/user-data/outputs/report.md"]
def test_scan_workspace_roots_can_skip_text_loading(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
(workspace / "visible.txt").write_text("visible", encoding="utf-8")
snapshot = scan_workspace_roots(roots, include_text=False)
file = snapshot.files["/mnt/user-data/workspace/visible.txt"]
assert file.sha256 is not None
assert file.text is None
assert file.content_unavailable_reason is None
def test_sensitive_workspace_path_covers_common_secret_names():
for filename in (
"password.txt",
"api_key.txt",
"apikey",
"private_key.json",
):
assert is_sensitive_workspace_path(f"/mnt/user-data/workspace/{filename}")
def test_compare_snapshots_hides_sensitive_and_binary_file_content(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
before = scan_workspace_roots(roots)
(workspace / ".env").write_text("SECRET_TOKEN=abc\n", encoding="utf-8")
(workspace / "image.png").write_bytes(b"\x89PNG\r\n\x1a\n\x00binary")
after = scan_workspace_roots(roots)
result = compare_snapshots(before, after)
changes = {change.path: change for change in result.files}
env_change = changes["/mnt/user-data/workspace/.env"]
assert env_change.sensitive is True
assert env_change.sha256_before is None
assert env_change.sha256_after is None
assert env_change.diff == ""
assert env_change.diff_unavailable_reason == "sensitive"
binary_change = changes["/mnt/user-data/workspace/image.png"]
assert binary_change.binary is True
assert binary_change.diff == ""
assert binary_change.diff_unavailable_reason == "binary"
@pytest.fixture
def symlink_support(tmp_path):
# Real symlink creation needs elevated privilege on stock Windows (no Developer
# Mode / admin); skip gracefully there instead of failing the whole run. Linux/CI
# and WSL create symlinks natively, so this exercises the real behavior there.
probe_link = tmp_path / "_symlink_probe"
try:
probe_link.symlink_to(tmp_path)
except (OSError, NotImplementedError):
pytest.skip("symlink creation is not permitted on this platform/user")
probe_link.unlink()
def test_compare_snapshots_classifies_symlink_replacing_file_as_symlink_created(tmp_path, symlink_support):
roots = _roots(tmp_path)
workspace = roots[0].host_path
outside_target = tmp_path / "outside-secret.txt"
outside_target.write_text("host-side content outside the workspace root\n", encoding="utf-8")
(workspace / "config.txt").write_text("original tracked content\n", encoding="utf-8")
before = scan_workspace_roots(roots)
assert before.files["/mnt/user-data/workspace/config.txt"].symlink is False
# Simulate an agent run doing: rm config.txt && ln -s <outside path> config.txt
(workspace / "config.txt").unlink()
(workspace / "config.txt").symlink_to(outside_target)
after = scan_workspace_roots(roots)
result = compare_snapshots(before, after)
changes = {change.path: change for change in result.files}
change = changes["/mnt/user-data/workspace/config.txt"]
assert change.status == "symlink_created"
assert change.status != "deleted"
assert change.symlink is True
assert change.symlink_target_after == str(outside_target)
assert change.diff_unavailable_reason == "symlink"
assert change.diff == ""
assert result.summary.symlink_created == 1
assert result.summary.deleted == 0
assert result.has_changes() is True
def test_scan_workspace_roots_captures_symlinks_as_metadata_only_stubs(tmp_path, symlink_support):
roots = _roots(tmp_path)
workspace = roots[0].host_path
outside_target = tmp_path / "outside-target.txt"
outside_target.write_text("outside content\n", encoding="utf-8")
(workspace / "link.txt").symlink_to(outside_target)
snapshot = scan_workspace_roots(roots)
file = snapshot.files["/mnt/user-data/workspace/link.txt"]
assert file.symlink is True
assert file.symlink_target == str(outside_target)
assert file.text is None
assert file.sha256 is None
assert file.content_unavailable_reason == "symlink"
def test_compare_snapshots_reports_removed_symlink_without_replacement_as_deleted(tmp_path, symlink_support):
# Scope boundary: a symlink that is genuinely removed with nothing taking its
# place is still "deleted" - only a symlink *newly occupying* a path (created or
# replacing a prior non-symlink) gets the distinct "symlink_created" status.
roots = _roots(tmp_path)
workspace = roots[0].host_path
outside_target = tmp_path / "outside-target.txt"
outside_target.write_text("outside content\n", encoding="utf-8")
(workspace / "link.txt").symlink_to(outside_target)
before = scan_workspace_roots(roots)
(workspace / "link.txt").unlink()
after = scan_workspace_roots(roots)
result = compare_snapshots(before, after)
changes = {change.path: change for change in result.files}
assert changes["/mnt/user-data/workspace/link.txt"].status == "deleted"
def test_compare_snapshots_truncates_large_text_diffs(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
before = scan_workspace_roots(roots)
(workspace / "large.txt").write_text("0123456789\n" * 20, encoding="utf-8")
after = scan_workspace_roots(
roots,
limits=WorkspaceChangeLimits(max_file_bytes_for_diff=32),
)
result = compare_snapshots(before, after)
change = result.files[0]
assert change.path == "/mnt/user-data/workspace/large.txt"
assert change.sha256_before is None
assert change.sha256_after is None
assert change.diff == ""
assert change.diff_unavailable_reason == "large"
assert result.summary.truncated is True
@pytest.mark.asyncio
async def test_workspace_changes_response_returns_summary_only_and_full_payload():
store = MemoryRunEventStore()
payload = {
"version": 1,
"summary": {
"created": 1,
"modified": 0,
"deleted": 0,
"additions": 2,
"deletions": 0,
"truncated": False,
},
"files": [
{
"path": "/mnt/user-data/outputs/report.md",
"root": "outputs",
"status": "created",
"binary": False,
"sensitive": False,
"size_before": None,
"size_after": 12,
"sha256_before": None,
"sha256_after": "abc",
"diff": "+hello",
"diff_truncated": False,
"diff_unavailable_reason": None,
"additions": 1,
"deletions": 0,
}
],
"limits": {
"max_files": 200,
"max_file_bytes_for_diff": 262144,
"max_total_diff_bytes": 1048576,
},
}
await store.put(
thread_id="thread-1",
run_id="run-1",
event_type="workspace_changes",
category="workspace",
content="1 file changed +2 -0",
metadata={"workspace_changes": payload},
)
summary = await get_workspace_changes_response(
store,
"thread-1",
"run-1",
include_files=False,
)
metadata_only = await get_workspace_changes_response(
store,
"thread-1",
"run-1",
include_files=True,
include_diff=False,
)
full = await get_workspace_changes_response(
store,
"thread-1",
"run-1",
include_files=True,
)
assert summary["available"] is True
assert summary["summary"]["created"] == 1
assert summary["files"] == []
assert metadata_only["files"][0]["path"] == "/mnt/user-data/outputs/report.md"
assert metadata_only["files"][0]["diff"] == ""
assert full["files"][0]["diff"] == "+hello"
@pytest.mark.asyncio
async def test_workspace_changes_response_is_empty_when_no_event_exists():
response = await get_workspace_changes_response(
MemoryRunEventStore(),
"thread-1",
"run-1",
)
assert response["available"] is False
assert response["summary"] == {
"created": 0,
"modified": 0,
"deleted": 0,
"symlink_created": 0,
"additions": 0,
"deletions": 0,
"truncated": False,
}
assert response["files"] == []
@pytest.mark.anyio
async def test_run_agent_records_workspace_changes_event(tmp_path, monkeypatch):
from deerflow.config import paths as paths_module
monkeypatch.setattr(paths_module, "_paths", Paths(tmp_path))
event_store = MemoryRunEventStore()
run_manager = RunManager()
record = await run_manager.create("thread-1")
user_id = get_effective_user_id()
paths_module.get_paths().ensure_thread_dirs("thread-1", user_id=user_id)
class DummyBridge:
async def publish(self, run_id, event, data):
return None
async def publish_end(self, run_id):
return None
async def cleanup(self, run_id, delay):
return None
class DummyAgent:
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
workspace = paths_module.get_paths().sandbox_work_dir("thread-1", user_id=user_id)
(workspace / "report.md").write_text("# Report\n\nReady\n", encoding="utf-8")
yield {"messages": []}
def factory(*, config):
return DummyAgent()
await run_agent(
DummyBridge(),
run_manager,
record,
ctx=RunContext(checkpointer=None, event_store=event_store),
agent_factory=factory,
graph_input={},
config={},
)
events = await event_store.list_events(
"thread-1",
record.run_id,
event_types=["workspace_changes"],
)
assert len(events) == 1
payload = events[0]["metadata"]["workspace_changes"]
assert payload["summary"]["created"] == 1
assert payload["files"][0]["path"] == "/mnt/user-data/workspace/report.md"
assert "+# Report" in payload["files"][0]["diff"]
@pytest.mark.anyio
async def test_record_workspace_changes_content_uses_total_changed_count(tmp_path, monkeypatch):
from deerflow.config import paths as paths_module
monkeypatch.setattr(paths_module, "_paths", Paths(tmp_path))
user_id = get_effective_user_id()
paths_module.get_paths().ensure_thread_dirs("thread-1", user_id=user_id)
workspace = paths_module.get_paths().sandbox_work_dir("thread-1", user_id=user_id)
(workspace / "unchanged.txt").write_text("keep me\n", encoding="utf-8")
before = await capture_workspace_snapshot(
"thread-1",
user_id=user_id,
limits=WorkspaceChangeLimits(max_files=1),
)
(workspace / "a.txt").write_text("a\n", encoding="utf-8")
(workspace / "b.txt").write_text("b\n", encoding="utf-8")
assert before.files["/mnt/user-data/workspace/unchanged.txt"].text is None
store = MemoryRunEventStore()
await record_workspace_changes(
store,
"thread-1",
"run-1",
before,
user_id=user_id,
limits=WorkspaceChangeLimits(max_files=1),
)
events = await store.list_events("thread-1", "run-1", event_types=["workspace_changes"])
assert events[0]["content"] == "2 files changed +2 -0"
payload = events[0]["metadata"]["workspace_changes"]
assert payload["summary"]["created"] == 2
assert len(payload["files"]) == 1
@pytest.mark.anyio
async def test_record_workspace_changes_uses_cached_baseline_for_modified_diff(tmp_path, monkeypatch):
from deerflow.config import paths as paths_module
monkeypatch.setattr(paths_module, "_paths", Paths(tmp_path))
user_id = get_effective_user_id()
paths_module.get_paths().ensure_thread_dirs("thread-1", user_id=user_id)
workspace = paths_module.get_paths().sandbox_work_dir("thread-1", user_id=user_id)
(workspace / "edit.txt").write_text("old\n", encoding="utf-8")
before = await capture_workspace_snapshot("thread-1", user_id=user_id)
assert before.files["/mnt/user-data/workspace/edit.txt"].text is None
assert before.text_cache_dir is not None
text_cache_dir = Path(before.text_cache_dir)
assert text_cache_dir.exists()
(workspace / "edit.txt").write_text("new\n", encoding="utf-8")
store = MemoryRunEventStore()
await record_workspace_changes(
store,
"thread-1",
"run-1",
before,
user_id=user_id,
)
events = await store.list_events("thread-1", "run-1", event_types=["workspace_changes"])
diff = events[0]["metadata"]["workspace_changes"]["files"][0]["diff"]
assert "-old" in diff
assert "+new" in diff
assert not text_cache_dir.exists()
@pytest.mark.anyio
async def test_workspace_changes_route_forwards_include_files_flag():
from app.gateway.routers.thread_runs import get_run_workspace_changes
calls: dict = {}
class FakeStore:
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None, user_id=None):
calls.update(thread_id=thread_id, run_id=run_id, event_types=event_types)
return [
{
"metadata": {
"workspace_changes": {
"version": 1,
"summary": {
"created": 1,
"modified": 0,
"deleted": 0,
"additions": 1,
"deletions": 0,
"truncated": False,
},
"files": [{"path": "/mnt/user-data/workspace/report.md", "diff": "+hello"}],
"limits": {},
}
}
}
]
class FakeState:
run_event_store = FakeStore()
class FakeApp:
state = FakeState()
class FakeRequest:
app = FakeApp()
_deerflow_test_bypass_auth = True
cookies: dict = {}
response = await get_run_workspace_changes(
thread_id="thread-1",
run_id="run-1",
request=FakeRequest(),
include_files=False,
include_diff=False,
)
assert response["available"] is True
assert response["files"] == []
assert calls["event_types"] == ["workspace_changes"]
def test_normalize_symlink_target_strips_extended_length_drive_prefix(monkeypatch):
# The strip only applies on Windows hosts, so force the platform here;
# this test runs on the ubuntu-only CI too.
monkeypatch.setattr(os, "name", "nt")
assert _normalize_symlink_target(r"\\?\C:\Users\u1\target.txt") == r"C:\Users\u1\target.txt"
def test_normalize_symlink_target_strips_extended_length_unc_prefix(monkeypatch):
monkeypatch.setattr(os, "name", "nt")
assert _normalize_symlink_target(r"\\?\UNC\server\share\a.txt") == r"\\server\share\a.txt"
def test_normalize_symlink_target_preserves_volume_guid_and_degenerate_prefixes(monkeypatch):
monkeypatch.setattr(os, "name", "nt")
# Volume-GUID paths are absolute Windows targets in their own namespace;
# stripping the prefix would leave a relative-looking path.
target = r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\folder\target.txt"
assert _normalize_symlink_target(target) == target
# Only a drive letter followed by a colon and a separator is a drive path.
for degenerate in (r"\\?\C:", r"\\?\1:\x", r"\\?\:"):
assert _normalize_symlink_target(degenerate) == degenerate
def test_normalize_symlink_target_leaves_relative_and_plain_posix_targets_verbatim():
assert _normalize_symlink_target("relative/target.txt") == "relative/target.txt"
assert _normalize_symlink_target("/tmp/target.txt") == "/tmp/target.txt"
def test_normalize_symlink_target_leaves_mid_string_prefix_verbatim():
# Backslash is a legal filename byte on POSIX, so only a *leading*
# extended-length prefix may ever be stripped.
for target in (r"/data/\\?\weird-target.txt", r"C:\data\\?\nested.txt"):
assert _normalize_symlink_target(target) == target
def test_normalize_symlink_target_is_identity_off_windows(monkeypatch):
# The strip is gated on Windows hosts: readlink(2) on POSIX returns the
# literal string the link was created with, so a target that starts with
# "\\?\" there must be recorded verbatim.
monkeypatch.setattr(os, "name", "posix")
assert _normalize_symlink_target(r"\\?\C:\Users\u1\target.txt") == r"\\?\C:\Users\u1\target.txt"