deer-flow/backend/tests/test_workspace_changes.py
Ryker_Feng 00161811bd
feat: add workspace change review for agent runs (#3945)
* feat: add workspace change review

* chore: format workspace change files

* fix: optimize workspace change summaries

* style: refine workspace change badge scale

* fix: restore workspace change user context import

* fix(frontend): gate workspace change badge to assistant messages

Only pass run_id to assistant MessageListItems so the workspace-change
badge can never render under a user's prompt, which carries the same
run_id. Assert single badge render in the E2E flow.

* fix(workspace-changes): address review feedback on diff parsing and badge

- Restrict unified-diff header detection to "+++ "/"--- " (trailing space)
  in both backend _count_diff_lines and frontend getWorkspaceChangeLineClass
  so content lines beginning with +++/--- are counted/styled correctly
- Gate WorkspaceChangeBadge to ai messages so tool messages folded into an
  assistant group don't render a duplicate badge
- Add regression tests for both diff-classification fixes
- Apply prettier formatting to workspace-changes files flagged by CI

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-05 15:38:01 +08:00

445 lines
14 KiB
Python

from __future__ import annotations
from pathlib import Path
import pytest
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,
record_workspace_changes,
scan_workspace_roots,
)
from deerflow.workspace_changes.api import get_workspace_changes_response
from deerflow.workspace_changes.scanner import 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_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_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_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"
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,
"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):
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
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"]