mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
* 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>
445 lines
14 KiB
Python
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"]
|