diff --git a/README.md b/README.md index cab20ed94..132388721 100644 --- a/README.md +++ b/README.md @@ -702,6 +702,8 @@ DeerFlow doesn't just *talk* about doing things. It has its own computer. Each task gets its own execution environment with a full filesystem view — skills, workspace, uploads, outputs. The agent reads, writes, and edits files. It can view images and, when configured safely, execute shell commands. +After each run, DeerFlow records a workspace change summary for the run-owned `workspace` and `outputs` directories. The Web UI shows a compact "files changed" badge on the assistant turn; opening it reveals created, modified, and deleted files with text diffs when safe to display. Uploads are excluded because they are user inputs, not agent-generated changes. Large, binary, or sensitive-looking files are shown as metadata only. + With `AioSandboxProvider`, shell execution runs inside isolated containers. With `LocalSandboxProvider`, file tools still map to per-thread directories on the host, but host `bash` is disabled by default because it is not a secure isolation boundary. Re-enable host bash only for fully trusted local workflows. Host bash commands have a wall-clock timeout, and long-lived processes should be started in the background with output redirected to a workspace log. This is the difference between a chatbot with tool access and an agent with an actual execution environment. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 31662d8ce..7e1b1929c 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -309,12 +309,20 @@ CORS is same-origin by default when requests enter through nginx on port 2026. S | **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; unexpected failures are logged server-side and return a generic 500 detail | | **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types | | **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`...`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing | -| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest assistant answer; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens | +| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest assistant answer; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens | | **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific | | **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id | | **GitHub Webhooks** (`/api/webhooks/github`) | `POST /` - receive GitHub App / repo webhook deliveries. Verifies `X-Hub-Signature-256` against `GITHUB_WEBHOOK_SECRET`; exempt from auth + CSRF because authenticity is enforced by HMAC. The route is fail-closed: mounted only when `GITHUB_WEBHOOK_SECRET` is set, or when explicit dev opt-in `DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS=1` is set. Recognized events include `ping`, `issues`, `issue_comment`, `pull_request`, `pull_request_review`, and `pull_request_review_comment`; unknown events return 200 with `handled=false`. Fan-out runtime failures return 503 so GitHub retries; permanent/non-retryable conditions such as `channels.github.enabled: false`, unknown events, malformed payloads, or unavailable channel service return 200 with a skipped/handled response. | | **GitHub Event-Driven Agents** | Custom agents can declare a `github:` block in their `config.yaml` to bind to repos and event triggers. Webhook fan-out publishes one `InboundMessage` per matching binding to the channel bus; `GitHubChannel` routes those messages through `ChannelManager`. The response `dispatch` summarizes matched/fired/skipped agents. | +**Workspace change review**: `packages/harness/deerflow/workspace_changes/` +captures a pre-run and post-run snapshot of the thread-owned `workspace` and +`outputs` directories. `runtime/runs/worker.py` performs the filesystem scan via +`asyncio.to_thread` and writes a `workspace_changes` event with category +`workspace` when changes exist. Uploads are intentionally excluded. Text diffs +are size-limited; binary, large, and sensitive-looking paths are persisted as +metadata only. + **RunManager / RunStore contract**: - `RunManager.get()` is async; direct callers must `await` it. - When a persistent `RunStore` is configured, `get()` and `list_by_thread()` hydrate historical runs from the store. In-memory records win for the same `run_id` so task, abort, and stream-control state stays attached to active local runs. diff --git a/backend/app/gateway/routers/thread_runs.py b/backend/app/gateway/routers/thread_runs.py index 3e6cf6de4..41282bac9 100644 --- a/backend/app/gateway/routers/thread_runs.py +++ b/backend/app/gateway/routers/thread_runs.py @@ -26,6 +26,7 @@ from app.gateway.pagination import trim_run_message_page from app.gateway.services import sse_consumer, start_run, wait_for_run_completion from deerflow.runtime import RunRecord, RunStatus, serialize_channel_values_for_api from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, get_original_user_content_text, message_to_text +from deerflow.workspace_changes import get_workspace_changes_response logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/threads", tags=["runs"]) @@ -742,6 +743,26 @@ async def list_run_events( return await event_store.list_events(thread_id, run_id, event_types=types, task_id=task_id, limit=limit, after_seq=after_seq) +@router.get("/{thread_id}/runs/{run_id}/workspace-changes") +@require_permission("runs", "read", owner_check=True) +async def get_run_workspace_changes( + thread_id: str, + run_id: str, + request: Request, + include_files: bool = Query(default=True), + include_diff: bool = Query(default=True), +) -> dict: + """Return workspace/output file changes recorded for one run.""" + event_store = get_run_event_store(request) + return await get_workspace_changes_response( + event_store, + thread_id, + run_id, + include_files=include_files, + include_diff=include_diff, + ) + + @router.get("/{thread_id}/token-usage", response_model=ThreadTokenUsageResponse) @require_permission("threads", "read", owner_check=True) async def thread_token_usage( diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py index eff0da400..b8ca4c0dd 100644 --- a/backend/app/gateway/routers/threads.py +++ b/backend/app/gateway/routers/threads.py @@ -766,15 +766,32 @@ async def get_thread_history(thread_id: str, body: ThreadHistoryRequest, request if isinstance(content, dict) and content.get("type") == "ai" and "id" in content: msg_to_run[content["id"]] = e["run_id"] - # 3. Inject the exact correct duration into each AI message + # 3. Attach the owning run_id to replayed messages. + # Raw LangGraph checkpoint messages do not carry a + # native run link. Message events are exact when + # present, but historical/runtime stores can miss + # them; the user-input message already records the + # run id for the whole turn, so use it as the + # fallback for following AI/tool messages. + current_turn_run_id = None for msg in serialized_msgs: - if msg.get("type") == "ai": + if msg.get("type") == "human": + additional_kwargs = msg.get("additional_kwargs") + if isinstance(additional_kwargs, dict): + run_id = additional_kwargs.get("run_id") + if isinstance(run_id, str) and run_id: + current_turn_run_id = run_id + continue + + if msg.get("type") in {"ai", "tool"}: msg_id = msg.get("id") - run_id = msg_to_run.get(msg_id) - if run_id and run_id in run_durations: - if "additional_kwargs" not in msg: - msg["additional_kwargs"] = {} - msg["additional_kwargs"]["turn_duration"] = run_durations[run_id] + run_id = msg_to_run.get(msg_id) or current_turn_run_id + if run_id: + msg["run_id"] = run_id + if msg.get("type") == "ai" and run_id in run_durations: + if "additional_kwargs" not in msg: + msg["additional_kwargs"] = {} + msg["additional_kwargs"]["turn_duration"] = run_durations[run_id] except Exception: logger.warning("Failed to inject turn_duration for thread %s", thread_id, exc_info=True) diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py index 0dabf5a99..54537ba66 100644 --- a/backend/packages/harness/deerflow/runtime/runs/worker.py +++ b/backend/packages/harness/deerflow/runtime/runs/worker.py @@ -49,10 +49,12 @@ from deerflow.runtime.goal import ( ) from deerflow.runtime.serialization import serialize from deerflow.runtime.stream_bridge import StreamBridge -from deerflow.runtime.user_context import resolve_runtime_user_id +from deerflow.runtime.user_context import get_effective_user_id, resolve_runtime_user_id from deerflow.trace_context import DEERFLOW_TRACE_METADATA_KEY, get_current_trace_id, normalize_trace_id from deerflow.tracing import inject_langfuse_metadata from deerflow.utils.messages import message_to_text +from deerflow.workspace_changes import capture_workspace_snapshot, record_workspace_changes +from deerflow.workspace_changes.types import WorkspaceSnapshot from .manager import RunManager, RunRecord from .naming import resolve_root_run_name @@ -230,6 +232,8 @@ async def run_agent( requested_modes: set[str] = set(stream_modes or ["values"]) pre_run_checkpoint_id: str | None = None pre_run_snapshot: dict[str, Any] | None = None + pre_run_workspace_snapshot: WorkspaceSnapshot | None = None + workspace_changes_user_id: str | None = None snapshot_capture_failed = False llm_error_fallback_message: str | None = None # Message ids checkpointed *before* this run started. The stream loop uses @@ -274,6 +278,16 @@ async def run_agent( # 1. Mark running await run_manager.set_status(run_id, RunStatus.running) + if event_store is not None: + workspace_changes_user_id = get_effective_user_id() + try: + pre_run_workspace_snapshot = await capture_workspace_snapshot( + thread_id, + user_id=workspace_changes_user_id, + ) + except Exception: + logger.warning("Could not capture pre-run workspace snapshot for run %s", run_id, exc_info=True) + # Snapshot the latest pre-run checkpoint so rollback can restore it. if checkpointer is not None: try: @@ -551,6 +565,18 @@ async def run_agent( if subagent_events is not None: await subagent_events.flush() + if event_store is not None and pre_run_workspace_snapshot is not None: + try: + await record_workspace_changes( + event_store, + thread_id, + run_id, + pre_run_workspace_snapshot, + user_id=workspace_changes_user_id, + ) + except Exception: + logger.warning("Failed to record workspace changes for run %s", run_id, exc_info=True) + # Flush any buffered journal events and persist completion data if journal is not None: try: diff --git a/backend/packages/harness/deerflow/workspace_changes/__init__.py b/backend/packages/harness/deerflow/workspace_changes/__init__.py new file mode 100644 index 000000000..b81b38737 --- /dev/null +++ b/backend/packages/harness/deerflow/workspace_changes/__init__.py @@ -0,0 +1,33 @@ +from .api import get_workspace_changes_response +from .diff import compare_snapshots, get_changed_paths +from .recorder import capture_workspace_snapshot, record_workspace_changes +from .scanner import scan_workspace_roots +from .types import ( + WORKSPACE_CHANGES_EVENT_TYPE, + WORKSPACE_CHANGES_METADATA_KEY, + FileSnapshot, + WorkspaceChangeLimits, + WorkspaceChangeResult, + WorkspaceChangeSummary, + WorkspaceFileChange, + WorkspaceRoot, + WorkspaceSnapshot, +) + +__all__ = [ + "WORKSPACE_CHANGES_EVENT_TYPE", + "WORKSPACE_CHANGES_METADATA_KEY", + "FileSnapshot", + "WorkspaceChangeLimits", + "WorkspaceChangeResult", + "WorkspaceChangeSummary", + "WorkspaceFileChange", + "WorkspaceRoot", + "WorkspaceSnapshot", + "capture_workspace_snapshot", + "compare_snapshots", + "get_changed_paths", + "get_workspace_changes_response", + "record_workspace_changes", + "scan_workspace_roots", +] diff --git a/backend/packages/harness/deerflow/workspace_changes/api.py b/backend/packages/harness/deerflow/workspace_changes/api.py new file mode 100644 index 000000000..f6cfc707d --- /dev/null +++ b/backend/packages/harness/deerflow/workspace_changes/api.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from typing import Any + +from .types import WORKSPACE_CHANGES_EVENT_TYPE, WORKSPACE_CHANGES_METADATA_KEY + +EMPTY_SUMMARY = { + "created": 0, + "modified": 0, + "deleted": 0, + "additions": 0, + "deletions": 0, + "truncated": False, +} + + +async def get_workspace_changes_response( + event_store: Any, + thread_id: str, + run_id: str, + *, + include_files: bool = True, + include_diff: bool = True, +) -> dict[str, Any]: + events = await event_store.list_events( + thread_id, + run_id, + event_types=[WORKSPACE_CHANGES_EVENT_TYPE], + limit=10, + ) + if not events: + return _empty_response() + + payload = _extract_workspace_changes_payload(events[-1]) + if not isinstance(payload, dict): + return _empty_response() + + response = dict(payload) + response["available"] = True + response.setdefault("summary", dict(EMPTY_SUMMARY)) + if include_files: + response.setdefault("files", []) + if not include_diff: + response["files"] = [_without_diff(file) for file in response["files"]] + else: + response["files"] = [] + return response + + +def _empty_response() -> dict[str, Any]: + return { + "available": False, + "version": 1, + "summary": dict(EMPTY_SUMMARY), + "files": [], + "limits": {}, + } + + +def _extract_workspace_changes_payload(event: dict[str, Any]) -> Any: + metadata = event.get("metadata") or {} + if isinstance(metadata, dict) and WORKSPACE_CHANGES_METADATA_KEY in metadata: + return metadata[WORKSPACE_CHANGES_METADATA_KEY] + content = event.get("content") + if isinstance(content, dict): + return content + return None + + +def _without_diff(file: Any) -> Any: + if not isinstance(file, dict): + return file + sanitized = dict(file) + sanitized["diff"] = "" + return sanitized diff --git a/backend/packages/harness/deerflow/workspace_changes/diff.py b/backend/packages/harness/deerflow/workspace_changes/diff.py new file mode 100644 index 000000000..844a65a42 --- /dev/null +++ b/backend/packages/harness/deerflow/workspace_changes/diff.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import difflib + +from .types import ( + DiffUnavailableReason, + FileSnapshot, + WorkspaceChangeLimits, + WorkspaceChangeResult, + WorkspaceChangeStatus, + WorkspaceChangeSummary, + WorkspaceFileChange, + WorkspaceSnapshot, +) + + +def compare_snapshots( + before: WorkspaceSnapshot, + after: WorkspaceSnapshot, + *, + limits: WorkspaceChangeLimits | None = None, +) -> WorkspaceChangeResult: + resolved_limits = limits or WorkspaceChangeLimits() + all_paths = sorted(set(before.files) | set(after.files)) + changes: list[WorkspaceFileChange] = [] + created = modified = deleted = additions = deletions = 0 + total_diff_bytes = 0 + truncated = before.truncated or after.truncated + + for path in all_paths: + before_file = before.files.get(path) + after_file = after.files.get(path) + if before_file and after_file and _same_file(before_file, after_file): + continue + + status = _status(before_file, after_file) + if status == "created": + created += 1 + elif status == "modified": + modified += 1 + else: + deleted += 1 + + diff, line_additions, line_deletions, diff_truncated, reason = _build_diff( + path, + before_file, + after_file, + remaining_bytes=max(0, resolved_limits.max_total_diff_bytes - total_diff_bytes), + ) + if diff: + total_diff_bytes += len(diff.encode("utf-8")) + if diff_truncated or reason in {"large", "truncated"}: + truncated = True + additions += line_additions + deletions += line_deletions + + if len(changes) < resolved_limits.max_files: + sample = after_file or before_file + assert sample is not None + changes.append( + WorkspaceFileChange( + path=path, + root=sample.root, + status=status, + binary=bool((after_file or before_file).binary if (after_file or before_file) else False), + sensitive=bool((after_file or before_file).sensitive if (after_file or before_file) else False), + size_before=before_file.size if before_file else None, + size_after=after_file.size if after_file else None, + sha256_before=before_file.sha256 if before_file else None, + sha256_after=after_file.sha256 if after_file else None, + diff=diff, + diff_truncated=diff_truncated, + diff_unavailable_reason=reason, + additions=line_additions, + deletions=line_deletions, + ) + ) + else: + truncated = True + + return WorkspaceChangeResult( + summary=WorkspaceChangeSummary( + created=created, + modified=modified, + deleted=deleted, + additions=additions, + deletions=deletions, + truncated=truncated, + ), + files=changes, + limits=resolved_limits, + ) + + +def get_changed_paths(before: WorkspaceSnapshot, after: WorkspaceSnapshot) -> set[str]: + changed: set[str] = set() + for path in set(before.files) | set(after.files): + before_file = before.files.get(path) + after_file = after.files.get(path) + if before_file and after_file and _same_file(before_file, after_file): + continue + changed.add(path) + return changed + + +def _status( + before_file: FileSnapshot | None, + after_file: FileSnapshot | None, +) -> WorkspaceChangeStatus: + if before_file is None: + return "created" + if after_file is None: + return "deleted" + return "modified" + + +def _same_file(before_file: FileSnapshot, after_file: FileSnapshot) -> bool: + if before_file.sha256 is not None and after_file.sha256 is not None: + return before_file.sha256 == after_file.sha256 + return before_file.size == after_file.size and before_file.mtime_ns == after_file.mtime_ns + + +def _build_diff( + path: str, + before_file: FileSnapshot | None, + after_file: FileSnapshot | None, + *, + remaining_bytes: int, +) -> tuple[str, int, int, bool, DiffUnavailableReason | None]: + reason = _diff_unavailable_reason(before_file, after_file) + if reason is not None: + return "", 0, 0, False, reason + + before_text = _snapshot_text(before_file) if before_file else "" + after_text = _snapshot_text(after_file) if after_file else "" + + if before_file is not None and before_text is None: + return "", 0, 0, False, None + if after_file is not None and after_text is None: + return "", 0, 0, False, None + + lines = list( + difflib.unified_diff( + before_text.splitlines(), + after_text.splitlines(), + fromfile=f"a{path}", + tofile=f"b{path}", + lineterm="", + ) + ) + diff = "\n".join(lines) + additions, deletions = _count_diff_lines(lines) + if len(diff.encode("utf-8")) > remaining_bytes: + return "", additions, deletions, True, "truncated" + return diff, additions, deletions, False, None + + +def _diff_unavailable_reason( + before_file: FileSnapshot | None, + after_file: FileSnapshot | None, +) -> DiffUnavailableReason | None: + files = [file for file in (before_file, after_file) if file is not None] + for preferred in ("sensitive", "binary", "large"): + if any(file.content_unavailable_reason == preferred for file in files): + return preferred # type: ignore[return-value] + return None + + +def _snapshot_text(file: FileSnapshot | None) -> str | None: + if file is None: + return "" + if file.text is not None: + return file.text + if file.text_path: + try: + with open(file.text_path, encoding="utf-8") as cached: + return cached.read() + except (OSError, UnicodeDecodeError): + return None + return None + + +def _count_diff_lines(lines: list[str]) -> tuple[int, int]: + additions = 0 + deletions = 0 + for line in lines: + # Unified-diff file headers are "+++ " / "--- " with a trailing space; + # a bare "+++"/"---" prefix would also swallow real content lines whose + # text begins with those sequences (e.g. an added line "+++foo"). + if line.startswith("+++ ") or line.startswith("--- "): + continue + if line.startswith("+"): + additions += 1 + elif line.startswith("-"): + deletions += 1 + return additions, deletions diff --git a/backend/packages/harness/deerflow/workspace_changes/recorder.py b/backend/packages/harness/deerflow/workspace_changes/recorder.py new file mode 100644 index 000000000..b1fc893b3 --- /dev/null +++ b/backend/packages/harness/deerflow/workspace_changes/recorder.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import asyncio +import logging +import shutil +import tempfile +from pathlib import Path +from typing import Any + +from deerflow.config import get_paths + +from .diff import compare_snapshots, get_changed_paths +from .scanner import scan_workspace_roots +from .types import ( + WORKSPACE_CHANGES_EVENT_TYPE, + WORKSPACE_CHANGES_METADATA_KEY, + WorkspaceChangeLimits, + WorkspaceRoot, + WorkspaceSnapshot, +) + +logger = logging.getLogger(__name__) + + +def build_thread_workspace_roots(thread_id: str, *, user_id: str | None = None) -> list[WorkspaceRoot]: + paths = get_paths() + return [ + WorkspaceRoot( + name="workspace", + host_path=paths.sandbox_work_dir(thread_id, user_id=user_id), + virtual_prefix="/mnt/user-data/workspace", + ), + WorkspaceRoot( + name="outputs", + host_path=paths.sandbox_outputs_dir(thread_id, user_id=user_id), + virtual_prefix="/mnt/user-data/outputs", + ), + ] + + +async def capture_workspace_snapshot( + thread_id: str, + *, + user_id: str | None = None, + limits: WorkspaceChangeLimits | None = None, + include_text: bool = True, +) -> WorkspaceSnapshot: + roots = build_thread_workspace_roots(thread_id, user_id=user_id) + text_cache_dir = Path(tempfile.mkdtemp(prefix="deerflow-workspace-changes-")) if include_text else None + try: + return await asyncio.to_thread( + scan_workspace_roots, + roots, + limits=limits, + include_text=include_text, + text_cache_dir=text_cache_dir, + ) + except Exception: + if text_cache_dir is not None: + shutil.rmtree(text_cache_dir, ignore_errors=True) + raise + + +async def record_workspace_changes( + event_store: Any, + thread_id: str, + run_id: str, + before: WorkspaceSnapshot, + *, + user_id: str | None = None, + limits: WorkspaceChangeLimits | None = None, +) -> dict | None: + try: + roots = build_thread_workspace_roots(thread_id, user_id=user_id) + after_metadata = await asyncio.to_thread( + scan_workspace_roots, + roots, + limits=limits, + include_text=False, + ) + changed_paths = get_changed_paths(before, after_metadata) + after = await asyncio.to_thread( + scan_workspace_roots, + roots, + limits=limits, + include_text=True, + text_paths=changed_paths, + ) + result = compare_snapshots(before, after, limits=limits) + if not result.has_changes(): + return None + + payload = result.to_dict() + summary = result.summary + changed_file_count = summary.created + summary.modified + summary.deleted + content = f"{changed_file_count} file{'s' if changed_file_count != 1 else ''} changed +{summary.additions} -{summary.deletions}" + return await event_store.put( + thread_id=thread_id, + run_id=run_id, + event_type=WORKSPACE_CHANGES_EVENT_TYPE, + category="workspace", + content=content, + metadata={WORKSPACE_CHANGES_METADATA_KEY: payload}, + ) + finally: + _cleanup_snapshot_text_cache(before) + + +def _cleanup_snapshot_text_cache(snapshot: WorkspaceSnapshot) -> None: + if snapshot.text_cache_dir: + shutil.rmtree(snapshot.text_cache_dir, ignore_errors=True) diff --git a/backend/packages/harness/deerflow/workspace_changes/scanner.py b/backend/packages/harness/deerflow/workspace_changes/scanner.py new file mode 100644 index 000000000..d6461ae65 --- /dev/null +++ b/backend/packages/harness/deerflow/workspace_changes/scanner.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import fnmatch +import hashlib +import os +import shutil +from pathlib import Path + +from .types import ( + DiffUnavailableReason, + FileSnapshot, + WorkspaceChangeLimits, + WorkspaceRoot, + WorkspaceSnapshot, +) + +EXCLUDED_DIR_NAMES = { + ".git", + ".hg", + ".svn", + ".cache", + ".next", + ".venv", + "__pycache__", + "build", + "dist", + "node_modules", +} + +BINARY_EXTENSIONS = { + ".7z", + ".avif", + ".bmp", + ".class", + ".db", + ".dll", + ".dmg", + ".doc", + ".docx", + ".exe", + ".gif", + ".gz", + ".ico", + ".jar", + ".jpeg", + ".jpg", + ".mov", + ".mp3", + ".mp4", + ".o", + ".pdf", + ".png", + ".pyc", + ".so", + ".tar", + ".webp", + ".xls", + ".xlsx", + ".zip", +} + +SENSITIVE_PATH_PATTERNS = ( + ".env", + ".env.*", + "*api_key*", + "*apikey*", + "*.key", + "*.pem", + "*credential*", + "*password*", + "*private_key*", + "*secret*", + "*token*", +) + +SAMPLE_BYTES = 4096 + + +def is_sensitive_workspace_path(path: str) -> bool: + normalized = path.lower() + parts = [part.lower() for part in Path(path).parts] + basename = parts[-1] if parts else normalized + for pattern in SENSITIVE_PATH_PATTERNS: + if fnmatch.fnmatch(basename, pattern) or fnmatch.fnmatch(normalized, pattern): + return True + if any(fnmatch.fnmatch(part, pattern) for part in parts): + return True + return False + + +def scan_workspace_roots( + roots: list[WorkspaceRoot], + *, + limits: WorkspaceChangeLimits | None = None, + include_text: bool = True, + text_paths: set[str] | None = None, + text_cache_dir: Path | None = None, +) -> WorkspaceSnapshot: + resolved_limits = limits or WorkspaceChangeLimits() + cache_dir = Path(text_cache_dir) if text_cache_dir is not None else None + if cache_dir is not None: + cache_dir.mkdir(parents=True, exist_ok=True) + files: dict[str, FileSnapshot] = {} + scanned = 0 + truncated = False + + for root in roots: + if not root.host_path.exists(): + continue + + for dirpath, dirnames, filenames in os.walk(root.host_path, followlinks=False): + dirnames[:] = [dirname for dirname in dirnames if dirname not in EXCLUDED_DIR_NAMES and not (Path(dirpath) / dirname).is_symlink()] + for filename in sorted(filenames): + if scanned >= resolved_limits.max_scanned_files: + truncated = True + return WorkspaceSnapshot( + files=files, + truncated=truncated, + text_cache_dir=str(cache_dir) if cache_dir is not None else None, + ) + + host_file = Path(dirpath) / filename + if host_file.is_symlink() or not host_file.is_file(): + continue + + snapshot = _snapshot_file( + root, + host_file, + limits=resolved_limits, + include_text=include_text, + text_paths=text_paths, + text_cache_dir=cache_dir, + ) + if snapshot is not None: + files[snapshot.path] = snapshot + scanned += 1 + + return WorkspaceSnapshot( + files=files, + truncated=truncated, + text_cache_dir=str(cache_dir) if cache_dir is not None else None, + ) + + +def _snapshot_file( + root: WorkspaceRoot, + host_file: Path, + *, + limits: WorkspaceChangeLimits, + include_text: bool, + text_paths: set[str] | None, + text_cache_dir: Path | None, +) -> FileSnapshot | None: + try: + stat = host_file.stat() + size = stat.st_size + mtime_ns = stat.st_mtime_ns + relative = host_file.relative_to(root.host_path).as_posix() + virtual_path = f"{root.virtual_prefix}/{relative}" + sensitive = is_sensitive_workspace_path(virtual_path) + except OSError: + return None + + if sensitive: + return FileSnapshot( + path=virtual_path, + root=root.name, + size=size, + mtime_ns=mtime_ns, + sha256=None, + binary=False, + sensitive=True, + text=None, + content_unavailable_reason="sensitive", + ) + + try: + sample = host_file.read_bytes()[:SAMPLE_BYTES] if size <= SAMPLE_BYTES else _read_sample(host_file) + except OSError: + return None + + binary = host_file.suffix.lower() in BINARY_EXTENSIONS or _looks_binary(sample) + sha256 = _sha256_file(host_file) if size <= limits.max_file_bytes_for_diff else None + text: str | None = None + text_path: str | None = None + reason: DiffUnavailableReason | None = None + + should_include_text = include_text and (text_paths is None or virtual_path in text_paths) + + if binary: + reason = "binary" + elif size > limits.max_file_bytes_for_diff: + reason = "large" + elif not should_include_text: + text = None + elif text_cache_dir is not None: + text_path = str(_cache_text_file(host_file, virtual_path, text_cache_dir)) + else: + try: + text = host_file.read_text(encoding="utf-8") + except UnicodeDecodeError: + binary = True + reason = "binary" + except OSError: + return None + + return FileSnapshot( + path=virtual_path, + root=root.name, + size=size, + mtime_ns=mtime_ns, + sha256=sha256, + binary=binary, + sensitive=sensitive, + text=text, + text_path=text_path, + content_unavailable_reason=reason, + ) + + +def _cache_text_file(source: Path, virtual_path: str, cache_dir: Path) -> Path: + cache_name = hashlib.sha256(virtual_path.encode("utf-8")).hexdigest() + target = cache_dir / cache_name + shutil.copyfile(source, target) + return target + + +def _read_sample(path: Path) -> bytes: + with path.open("rb") as file: + return file.read(SAMPLE_BYTES) + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _looks_binary(sample: bytes) -> bool: + if b"\x00" in sample: + return True + try: + sample.decode("utf-8") + except UnicodeDecodeError: + return True + return False diff --git a/backend/packages/harness/deerflow/workspace_changes/types.py b/backend/packages/harness/deerflow/workspace_changes/types.py new file mode 100644 index 000000000..762988b63 --- /dev/null +++ b/backend/packages/harness/deerflow/workspace_changes/types.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Literal + +WORKSPACE_CHANGES_EVENT_TYPE = "workspace_changes" +WORKSPACE_CHANGES_METADATA_KEY = "workspace_changes" + +WorkspaceChangeStatus = Literal["created", "modified", "deleted"] +DiffUnavailableReason = Literal["binary", "large", "sensitive", "truncated"] + + +@dataclass(frozen=True) +class WorkspaceChangeLimits: + max_files: int = 200 + max_scanned_files: int = 2000 + max_file_bytes_for_diff: int = 256 * 1024 + max_total_diff_bytes: int = 1024 * 1024 + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass(frozen=True) +class WorkspaceRoot: + name: str + host_path: Path + virtual_prefix: str + + def __post_init__(self) -> None: + object.__setattr__(self, "host_path", Path(self.host_path)) + object.__setattr__(self, "virtual_prefix", self.virtual_prefix.rstrip("/")) + + +@dataclass(frozen=True) +class FileSnapshot: + path: str + root: str + size: int + mtime_ns: int + sha256: str | None + binary: bool = False + sensitive: bool = False + text: str | None = None + text_path: str | None = None + content_unavailable_reason: DiffUnavailableReason | None = None + + +@dataclass(frozen=True) +class WorkspaceSnapshot: + files: dict[str, FileSnapshot] = field(default_factory=dict) + truncated: bool = False + text_cache_dir: str | None = None + + +@dataclass(frozen=True) +class WorkspaceFileChange: + path: str + root: str + status: WorkspaceChangeStatus + binary: bool + sensitive: bool + size_before: int | None + size_after: int | None + sha256_before: str | None + sha256_after: str | None + diff: str = "" + diff_truncated: bool = False + diff_unavailable_reason: DiffUnavailableReason | None = None + additions: int = 0 + deletions: int = 0 + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass(frozen=True) +class WorkspaceChangeSummary: + created: int = 0 + modified: int = 0 + deleted: int = 0 + additions: int = 0 + deletions: int = 0 + truncated: bool = False + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass(frozen=True) +class WorkspaceChangeResult: + summary: WorkspaceChangeSummary + files: list[WorkspaceFileChange] + limits: WorkspaceChangeLimits = field(default_factory=WorkspaceChangeLimits) + version: int = 1 + + def has_changes(self) -> bool: + return bool(self.summary.created or self.summary.modified or self.summary.deleted or self.summary.additions or self.summary.deletions) + + def to_dict(self) -> dict: + return { + "version": self.version, + "summary": self.summary.to_dict(), + "files": [change.to_dict() for change in self.files], + "limits": self.limits.to_dict(), + } diff --git a/backend/tests/test_workspace_changes.py b/backend/tests/test_workspace_changes.py new file mode 100644 index 000000000..95519e66a --- /dev/null +++ b/backend/tests/test_workspace_changes.py @@ -0,0 +1,445 @@ +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"] diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 4150f3de0..b46cb27eb 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -53,7 +53,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat - `workspace/` — Chat page components (messages, artifacts, settings) - `landing/` — Landing page sections - `docs/` — Docs / MDX rendering components -- **`core/`** — Business logic, the heart of the app. Domains include `threads/` (creation, streaming, state), `api/` (LangGraph client singleton), `agents/` (custom agents), `auth/` (authentication), `artifacts/`, `channels/` (IM connections), `i18n/` (en-US, zh-CN), `settings/`, `memory/`, `skills/`, `messages/`, `mcp/`, `models/`, `suggestions/`, `tasks/`, `todos/`, `tools/`, `config/`, `notification/`, `blog/`, plus rendering helpers (`rehype/`, `streamdown/`) and `utils/`. +- **`core/`** — Business logic, the heart of the app. Domains include `threads/` (creation, streaming, state), `api/` (LangGraph client singleton), `agents/` (custom agents), `auth/` (authentication), `artifacts/`, `channels/` (IM connections), `i18n/` (en-US, zh-CN), `settings/`, `memory/`, `skills/`, `messages/`, `mcp/`, `models/`, `suggestions/`, `tasks/`, `todos/`, `tools/`, `workspace-changes/` (run-scoped changed-file summaries and diff fetching), `config/`, `notification/`, `blog/`, plus rendering helpers (`rehype/`, `streamdown/`) and `utils/`. - **`hooks/`** — Shared React hooks - **`lib/`** — Utilities (`cn()` from clsx + tailwind-merge) - **`content/`** — MDX content (blog posts, docs) rendered by the app diff --git a/frontend/src/components/workspace/changes/index.ts b/frontend/src/components/workspace/changes/index.ts new file mode 100644 index 000000000..1b1085eec --- /dev/null +++ b/frontend/src/components/workspace/changes/index.ts @@ -0,0 +1 @@ +export * from "./workspace-change-badge"; diff --git a/frontend/src/components/workspace/changes/workspace-change-badge.tsx b/frontend/src/components/workspace/changes/workspace-change-badge.tsx new file mode 100644 index 000000000..ff1ff3090 --- /dev/null +++ b/frontend/src/components/workspace/changes/workspace-change-badge.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { ArrowUpRightIcon, FileDiffIcon } from "lucide-react"; +import { useState } from "react"; + +import { Sheet } from "@/components/ui/sheet"; +import { useI18n } from "@/core/i18n/hooks"; +import { useWorkspaceChanges } from "@/core/workspace-changes/hooks"; +import { + getChangedFileCount, + sortWorkspaceChanges, +} from "@/core/workspace-changes/summary"; +import type { WorkspaceFileChange } from "@/core/workspace-changes/types"; +import { cn } from "@/lib/utils"; + +import { WorkspaceChangePanel } from "./workspace-change-panel"; + +export function WorkspaceChangeBadge({ + threadId, + runId, + disabled, +}: { + threadId: string; + runId?: string; + disabled?: boolean; +}) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const { data, isLoading } = useWorkspaceChanges({ + threadId, + runId, + includeFiles: true, + includeDiff: false, + enabled: Boolean(runId) && !disabled, + }); + + if (!runId || !data?.available) { + return null; + } + + const count = getChangedFileCount(data.summary); + if (count === 0) { + return null; + } + + const files = sortWorkspaceChanges(data.files); + + return ( + +
+
+
+
+ +
+
+
+ {t.workspaceChanges.editedTitle(count)} +
+ +
+
+ +
+ +
+ {isLoading && ( +
+ {t.workspaceChanges.loading} +
+ )} + {!isLoading && + files.map((file) => ( + + ))} +
+
+ +
+ ); +} + +function WorkspaceChangeSummaryRow({ file }: { file: WorkspaceFileChange }) { + const pathParts = formatWorkspacePath(file.path); + + return ( +
+
+ {pathParts.dirname && ( + {pathParts.dirname}/ + )} + + {pathParts.basename} + +
+ +
+ ); +} + +function SummaryDelta({ + additions, + deletions, + className, +}: { + additions: number; + deletions: number; + className?: string; +}) { + return ( + + +{additions} + -{deletions} + + ); +} + +function formatWorkspacePath(path: string) { + const compact = path + .replace(/^\/mnt\/user-data\/workspace\//, "") + .replace(/^\/mnt\/user-data\/outputs\//, "outputs/"); + const lastSlash = compact.lastIndexOf("/"); + if (lastSlash < 0) { + return { dirname: "", basename: compact }; + } + return { + dirname: compact.slice(0, lastSlash), + basename: compact.slice(lastSlash + 1), + }; +} diff --git a/frontend/src/components/workspace/changes/workspace-change-panel.tsx b/frontend/src/components/workspace/changes/workspace-change-panel.tsx new file mode 100644 index 000000000..517e6775a --- /dev/null +++ b/frontend/src/components/workspace/changes/workspace-change-panel.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { + ExternalLinkIcon, + FileDiffIcon, + FileMinusIcon, + FilePenLineIcon, + FilePlusIcon, +} from "lucide-react"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { resolveArtifactURL } from "@/core/artifacts/utils"; +import { useI18n } from "@/core/i18n/hooks"; +import { useWorkspaceChanges } from "@/core/workspace-changes/hooks"; +import { + getChangedFileCount, + getWorkspaceChangeLineClass, + sortWorkspaceChanges, +} from "@/core/workspace-changes/summary"; +import type { + DiffUnavailableReason, + WorkspaceChangeStatus, + WorkspaceChangeSummary, + WorkspaceFileChange, +} from "@/core/workspace-changes/types"; +import { cn } from "@/lib/utils"; + +export function WorkspaceChangePanel({ + threadId, + runId, + fallbackSummary, + open, +}: { + threadId: string; + runId: string; + fallbackSummary: WorkspaceChangeSummary; + open: boolean; +}) { + const { t } = useI18n(); + const { data, isLoading } = useWorkspaceChanges({ + threadId, + runId, + includeFiles: true, + enabled: open, + }); + const changes = data ?? { + available: true, + version: 1, + summary: fallbackSummary, + files: [], + limits: {}, + }; + const fileCount = getChangedFileCount(changes.summary); + const files = sortWorkspaceChanges(changes.files); + + return ( + + + + + {t.workspaceChanges.title} + + + {t.workspaceChanges.badge( + fileCount, + changes.summary.additions, + changes.summary.deletions, + )} + {changes.summary.truncated + ? ` · ${t.workspaceChanges.truncatedSummary}` + : ""} + + + +
+ {isLoading && ( +

+ {t.workspaceChanges.loading} +

+ )} + {!isLoading && files.length === 0 && ( +

+ {t.workspaceChanges.noChanges} +

+ )} +
+ {files.map((file) => ( + + ))} +
+
+
+ ); +} + +function WorkspaceChangeFile({ + file, + threadId, +}: { + file: WorkspaceFileChange; + threadId: string; +}) { + const { t } = useI18n(); + const hasDiff = file.diff.length > 0; + const openUrl = resolveArtifactURL(file.path, threadId); + const canOpenFile = file.status !== "deleted" && !file.sensitive; + + return ( + +
+ +
+ +
+
+ {file.path} +
+
+ {statusLabel(file.status, t)} + {(file.additions > 0 || file.deletions > 0) && ( + + +{file.additions}{" "} + -{file.deletions} + + )} +
+
+
+ {canOpenFile && ( + event.stopPropagation()} + > + + + )} +
+ + {hasDiff ? ( + + ) : ( +
+ {unavailableLabel(file.diff_unavailable_reason, t)} +
+ )} +
+
+
+ ); +} + +function WorkspaceDiff({ diff }: { diff: string }) { + return ( +
+      {diff.split("\n").map((line, index) => (
+        
+ {line || " "} +
+ ))} +
+ ); +} + +function StatusIcon({ status }: { status: WorkspaceChangeStatus }) { + const className = "mt-0.5 size-4 shrink-0"; + if (status === "created") { + return ; + } + if (status === "deleted") { + return ; + } + return ; +} + +function statusLabel( + status: WorkspaceChangeStatus, + t: ReturnType["t"], +) { + if (status === "created") { + return t.workspaceChanges.created; + } + if (status === "deleted") { + return t.workspaceChanges.deleted; + } + return t.workspaceChanges.modified; +} + +function unavailableLabel( + reason: DiffUnavailableReason | null, + t: ReturnType["t"], +) { + if (reason === "binary") { + return t.workspaceChanges.binaryUnavailable; + } + if (reason === "large") { + return t.workspaceChanges.largeUnavailable; + } + if (reason === "sensitive") { + return t.workspaceChanges.sensitiveUnavailable; + } + if (reason === "truncated") { + return t.workspaceChanges.truncatedUnavailable; + } + return t.workspaceChanges.diffUnavailable; +} + +function diffLineClassName(line: string) { + const lineClass = getWorkspaceChangeLineClass(line); + if (lineClass === "addition") { + return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; + } + if (lineClass === "deletion") { + return "bg-red-500/10 text-red-700 dark:text-red-300"; + } + if (lineClass === "hunk") { + return "bg-sky-500/10 text-sky-700 dark:text-sky-300"; + } + if (lineClass === "meta") { + return "text-muted-foreground"; + } + return "text-foreground"; +} diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 209870c29..c807bd6f3 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -47,6 +47,7 @@ import { readReferenceMessageContexts } from "@/core/sidecar"; import { SafeReasoningContent } from "@/core/streamdown/components"; import { cn } from "@/lib/utils"; +import { WorkspaceChangeBadge } from "../changes"; import { CitationSourcesPanel } from "../citations/citation-sources-panel"; import { CopyButton } from "../copy-button"; import { ReferenceAttachmentSummary } from "../sidecar/reference-attachments"; @@ -151,6 +152,7 @@ export function MessageListItem({ message={message} isLoading={isLoading} threadId={threadId} + runId={runId} turnStartTime={turnStartTime} /> {!isLoading && showCopyButton && ( @@ -215,12 +217,14 @@ function MessageContent_({ message, isLoading = false, threadId, + runId, turnStartTime, }: { className?: string; message: Message; isLoading?: boolean; threadId: string; + runId?: string; turnStartTime?: number | null; }) { const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); @@ -408,6 +412,13 @@ function MessageContent_({ components={components} /> + {message.type === "ai" && ( + + )} ); } diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 8feedf1f9..8c4b271e9 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -578,6 +578,11 @@ export function MessageList({ groupIndex === groupedMessages.length - 1 } threadId={threadId} + runId={ + group.type === "assistant" + ? (msg as { run_id?: string }).run_id + : undefined + } showCopyButton={group.type !== "assistant"} turnStartTime={ groupIndex === groupedMessages.length - 1 diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 8e9663233..a1a410dc8 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -87,6 +87,27 @@ export const enUS: Translations = { copiedReference: (title) => `Copied ${title} reference`, }, + // Workspace Changes + workspaceChanges: { + title: "Workspace changes", + editedTitle: (count) => `Edited ${count} ${count === 1 ? "file" : "files"}`, + badge: (count, additions, deletions) => + `${count} ${count === 1 ? "file" : "files"} changed +${additions} -${deletions}`, + viewChanges: "View changes", + created: "Created", + modified: "Modified", + deleted: "Deleted", + openFile: "Open file", + loading: "Loading workspace changes...", + noChanges: "No workspace changes recorded.", + diffUnavailable: "Diff unavailable", + binaryUnavailable: "Binary file. Diff unavailable.", + largeUnavailable: "Large file. Diff omitted.", + sensitiveUnavailable: "Sensitive path. Content hidden.", + truncatedUnavailable: "Diff omitted because the change set is too large.", + truncatedSummary: "Some changes were truncated.", + }, + // Input Box inputBox: { placeholder: "How can I assist you today?", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 8fa4f1beb..c07755235 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -71,6 +71,26 @@ export interface Translations { copiedReference: (title: string) => string; }; + // Workspace Changes + workspaceChanges: { + title: string; + editedTitle: (count: number) => string; + badge: (count: number, additions: number, deletions: number) => string; + viewChanges: string; + created: string; + modified: string; + deleted: string; + openFile: string; + loading: string; + noChanges: string; + diffUnavailable: string; + binaryUnavailable: string; + largeUnavailable: string; + sensitiveUnavailable: string; + truncatedUnavailable: string; + truncatedSummary: string; + }; + // Input Box inputBox: { placeholder: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 7e3eaec7f..d170b2676 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -86,6 +86,27 @@ export const zhCN: Translations = { copiedReference: (title) => `已复制 ${title} 引用`, }, + // Workspace Changes + workspaceChanges: { + title: "工作区变更", + editedTitle: (count) => `已编辑 ${count} 个文件`, + badge: (count, additions, deletions) => + `${count} 个文件已更改 +${additions} -${deletions}`, + viewChanges: "查看更改", + created: "新增", + modified: "修改", + deleted: "删除", + openFile: "打开文件", + loading: "正在加载工作区变更...", + noChanges: "没有记录到工作区变更。", + diffUnavailable: "无法展示 diff", + binaryUnavailable: "二进制文件,无法展示 diff。", + largeUnavailable: "文件过大,已省略 diff。", + sensitiveUnavailable: "敏感路径,已隐藏内容。", + truncatedUnavailable: "变更集过大,已省略 diff。", + truncatedSummary: "部分变更已被截断。", + }, + // Input Box inputBox: { placeholder: "今天我能为你做些什么?", diff --git a/frontend/src/core/workspace-changes/api.ts b/frontend/src/core/workspace-changes/api.ts new file mode 100644 index 000000000..34577d472 --- /dev/null +++ b/frontend/src/core/workspace-changes/api.ts @@ -0,0 +1,34 @@ +import { getBackendBaseURL } from "@/core/config"; + +import type { WorkspaceChangesResponse } from "./types"; + +export async function fetchWorkspaceChanges({ + threadId, + runId, + includeFiles = true, + includeDiff = true, +}: { + threadId: string; + runId: string; + includeFiles?: boolean; + includeDiff?: boolean; +}): Promise { + const query = new URLSearchParams({ + include_files: includeFiles ? "true" : "false", + include_diff: includeDiff ? "true" : "false", + }); + const response = await fetch( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent( + threadId, + )}/runs/${encodeURIComponent(runId)}/workspace-changes?${query}`, + ); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: "Failed to load workspace changes." })); + throw new Error(error.detail ?? "Failed to load workspace changes."); + } + + return response.json(); +} diff --git a/frontend/src/core/workspace-changes/hooks.ts b/frontend/src/core/workspace-changes/hooks.ts new file mode 100644 index 000000000..abe2af279 --- /dev/null +++ b/frontend/src/core/workspace-changes/hooks.ts @@ -0,0 +1,57 @@ +import { useQuery } from "@tanstack/react-query"; + +import { fetchWorkspaceChanges } from "./api"; +import type { WorkspaceChangesResponse } from "./types"; + +export function workspaceChangesQueryKey( + threadId: string | undefined, + runId: string | undefined, + includeFiles: boolean, + includeDiff: boolean, +) { + return [ + "workspace-changes", + threadId, + runId, + includeFiles, + includeDiff, + ] as const; +} + +export function useWorkspaceChanges({ + threadId, + runId, + includeFiles = true, + includeDiff = true, + enabled = true, +}: { + threadId?: string; + runId?: string; + includeFiles?: boolean; + includeDiff?: boolean; + enabled?: boolean; +}) { + return useQuery({ + queryKey: workspaceChangesQueryKey( + threadId, + runId, + includeFiles, + includeDiff, + ), + queryFn: () => { + if (!threadId || !runId) { + throw new Error("threadId and runId are required"); + } + return fetchWorkspaceChanges({ + threadId, + runId, + includeFiles, + includeDiff, + }); + }, + enabled: enabled && Boolean(threadId) && Boolean(runId), + retry: false, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + }); +} diff --git a/frontend/src/core/workspace-changes/index.ts b/frontend/src/core/workspace-changes/index.ts new file mode 100644 index 000000000..29503534f --- /dev/null +++ b/frontend/src/core/workspace-changes/index.ts @@ -0,0 +1,4 @@ +export * from "./api"; +export * from "./hooks"; +export * from "./summary"; +export * from "./types"; diff --git a/frontend/src/core/workspace-changes/summary.ts b/frontend/src/core/workspace-changes/summary.ts new file mode 100644 index 000000000..77b1b7d22 --- /dev/null +++ b/frontend/src/core/workspace-changes/summary.ts @@ -0,0 +1,54 @@ +import type { WorkspaceChangeSummary, WorkspaceFileChange } from "./types"; + +export type WorkspaceChangeLineClass = + | "addition" + | "context" + | "deletion" + | "hunk" + | "meta"; + +export function getChangedFileCount(summary: WorkspaceChangeSummary) { + return summary.created + summary.modified + summary.deleted; +} + +export function getWorkspaceChangeBadgeLabel(summary: WorkspaceChangeSummary) { + const count = getChangedFileCount(summary); + return `${count} ${count === 1 ? "file" : "files"} changed +${summary.additions} -${summary.deletions}`; +} + +export function getWorkspaceChangeLineClass( + line: string, +): WorkspaceChangeLineClass { + // Unified-diff file headers are "+++ " / "--- " with a trailing space. A bare + // "+++"/"---" prefix would also match real content lines that begin with those + // sequences (e.g. an added line "+++foo"), styling them as meta by mistake. + if (line.startsWith("+++ ") || line.startsWith("--- ")) { + return "meta"; + } + if (line.startsWith("@@")) { + return "hunk"; + } + if (line.startsWith("+")) { + return "addition"; + } + if (line.startsWith("-")) { + return "deletion"; + } + return "context"; +} + +export function sortWorkspaceChanges(files: WorkspaceFileChange[]) { + const statusRank = { + created: 0, + modified: 1, + deleted: 2, + } satisfies Record; + + return [...files].sort((left, right) => { + const rankDiff = statusRank[left.status] - statusRank[right.status]; + if (rankDiff !== 0) { + return rankDiff; + } + return left.path.localeCompare(right.path); + }); +} diff --git a/frontend/src/core/workspace-changes/types.ts b/frontend/src/core/workspace-changes/types.ts new file mode 100644 index 000000000..12ab682c6 --- /dev/null +++ b/frontend/src/core/workspace-changes/types.ts @@ -0,0 +1,41 @@ +export type WorkspaceChangeStatus = "created" | "modified" | "deleted"; + +export type DiffUnavailableReason = + | "binary" + | "large" + | "sensitive" + | "truncated"; + +export interface WorkspaceChangeSummary { + created: number; + modified: number; + deleted: number; + additions: number; + deletions: number; + truncated: boolean; +} + +export interface WorkspaceFileChange { + path: string; + root: string; + status: WorkspaceChangeStatus; + binary: boolean; + sensitive: boolean; + size_before: number | null; + size_after: number | null; + sha256_before: string | null; + sha256_after: string | null; + diff: string; + diff_truncated: boolean; + diff_unavailable_reason: DiffUnavailableReason | null; + additions: number; + deletions: number; +} + +export interface WorkspaceChangesResponse { + available: boolean; + version: number; + summary: WorkspaceChangeSummary; + files: WorkspaceFileChange[]; + limits: Record; +} diff --git a/frontend/tests/e2e/workspace-changes.spec.ts b/frontend/tests/e2e/workspace-changes.spec.ts new file mode 100644 index 000000000..0cd0b212c --- /dev/null +++ b/frontend/tests/e2e/workspace-changes.spec.ts @@ -0,0 +1,123 @@ +import { expect, test } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +const THREAD_ID = "00000000-0000-0000-0000-000000000321"; +const RUN_ID = "run-workspace-changes"; + +test.describe("Workspace changes", () => { + test("shows changed files badge and opens the diff panel", async ({ + page, + }) => { + const includeDiffValues: string[] = []; + mockLangGraphAPI(page, { + threads: [ + { + thread_id: THREAD_ID, + title: "Workspace changes", + updated_at: "2026-07-04T10:00:00Z", + messages: [ + { + type: "human", + id: "msg-human-workspace-changes", + content: [{ type: "text", text: "Create a report" }], + run_id: RUN_ID, + }, + { + type: "ai", + id: "msg-ai-workspace-changes", + content: "I updated the workspace report.", + run_id: RUN_ID, + }, + ], + }, + ], + }); + await page.route( + `**/api/threads/${THREAD_ID}/runs/${RUN_ID}/workspace-changes?*`, + async (route) => { + const url = new URL(route.request().url()); + const includeFiles = url.searchParams.get("include_files") !== "false"; + includeDiffValues.push(url.searchParams.get("include_diff") ?? ""); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + available: true, + version: 1, + summary: { + created: 1, + modified: 1, + deleted: 0, + additions: 8, + deletions: 2, + truncated: false, + }, + files: includeFiles + ? [ + { + path: "/mnt/user-data/outputs/report.md", + root: "outputs", + status: "modified", + binary: false, + sensitive: false, + size_before: 12, + size_after: 20, + sha256_before: "before", + sha256_after: "after", + diff: "--- a/mnt/user-data/outputs/report.md\n+++ b/mnt/user-data/outputs/report.md\n@@ -1,2 +1,2 @@\n-Draft\n+Ready", + diff_truncated: false, + diff_unavailable_reason: null, + additions: 1, + deletions: 1, + }, + { + path: "/mnt/user-data/workspace/notes.txt", + root: "workspace", + status: "created", + binary: false, + sensitive: false, + size_before: null, + size_after: 8, + sha256_before: null, + sha256_after: "created", + diff: "--- a/mnt/user-data/workspace/notes.txt\n+++ b/mnt/user-data/workspace/notes.txt\n@@ -0,0 +1 @@\n+Notes", + diff_truncated: false, + diff_unavailable_reason: null, + additions: 1, + deletions: 0, + }, + ] + : [], + limits: {}, + }), + }); + }, + ); + + await page.goto(`/workspace/chats/${THREAD_ID}`); + + await expect(page.getByText("Edited 2 files")).toBeVisible({ + timeout: 15_000, + }); + // The human prompt carries the same run_id, but the badge must only render + // under the assistant turn — never under the user's message. + await expect(page.getByText("Edited 2 files")).toHaveCount(1); + await expect(page.getByText("outputs/report.md")).toBeVisible(); + await expect(page.getByText("notes.txt")).toBeVisible(); + expect(includeDiffValues).toContain("false"); + + await page.getByRole("button", { name: "View changes" }).click(); + await expect( + page.getByRole("heading", { name: /workspace changes/i }), + ).toBeVisible(); + await expect( + page.getByRole("button", { + name: /\/mnt\/user-data\/outputs\/report\.md/i, + }), + ).toBeVisible(); + expect(includeDiffValues).toContain("true"); + await expect(page.getByText("+Ready")).toBeVisible(); + await expect(page.getByText("-Draft")).toBeVisible(); + }); +}); diff --git a/frontend/tests/unit/core/workspace-changes/api.test.ts b/frontend/tests/unit/core/workspace-changes/api.test.ts new file mode 100644 index 000000000..4ff402df1 --- /dev/null +++ b/frontend/tests/unit/core/workspace-changes/api.test.ts @@ -0,0 +1,50 @@ +import { afterEach, expect, test, rs } from "@rstest/core"; + +afterEach(() => { + rs.unstubAllGlobals(); +}); + +test("fetchWorkspaceChanges can request file metadata without diffs", async () => { + let requestedUrl = ""; + const fetchMock = rs.fn(async (input: RequestInfo | URL) => { + if (typeof input === "string") { + requestedUrl = input; + } else if (input instanceof URL) { + requestedUrl = input.toString(); + } else { + requestedUrl = input.url; + } + return new Response( + JSON.stringify({ + available: true, + version: 1, + summary: { + created: 1, + modified: 0, + deleted: 0, + additions: 1, + deletions: 0, + truncated: false, + }, + files: [], + limits: {}, + }), + { status: 200 }, + ); + }); + rs.stubGlobal("fetch", fetchMock); + + const { fetchWorkspaceChanges } = + await import("@/core/workspace-changes/api"); + + await fetchWorkspaceChanges({ + threadId: "thread-1", + runId: "run-1", + includeFiles: true, + includeDiff: false, + }); + + const url = new URL(requestedUrl, "http://localhost"); + expect(url.searchParams.get("include_files")).toBe("true"); + expect(url.searchParams.get("include_diff")).toBe("false"); +}); diff --git a/frontend/tests/unit/core/workspace-changes/summary.test.ts b/frontend/tests/unit/core/workspace-changes/summary.test.ts new file mode 100644 index 000000000..3d49410bb --- /dev/null +++ b/frontend/tests/unit/core/workspace-changes/summary.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "@rstest/core"; + +import { + getChangedFileCount, + getWorkspaceChangeBadgeLabel, + getWorkspaceChangeLineClass, +} from "@/core/workspace-changes/summary"; +import type { WorkspaceChangesResponse } from "@/core/workspace-changes/types"; + +const changes: WorkspaceChangesResponse = { + available: true, + version: 1, + summary: { + created: 1, + modified: 2, + deleted: 0, + additions: 12, + deletions: 3, + truncated: false, + }, + files: [], + limits: {}, +}; + +describe("workspace change summary helpers", () => { + test("counts created, modified, and deleted files", () => { + expect(getChangedFileCount(changes.summary)).toBe(3); + }); + + test("formats the compact badge label", () => { + expect(getWorkspaceChangeBadgeLabel(changes.summary)).toBe( + "3 files changed +12 -3", + ); + }); + + test("classifies unified diff lines", () => { + expect(getWorkspaceChangeLineClass("+new line")).toBe("addition"); + expect(getWorkspaceChangeLineClass("-old line")).toBe("deletion"); + expect(getWorkspaceChangeLineClass("@@ -1 +1 @@")).toBe("hunk"); + expect(getWorkspaceChangeLineClass(" unchanged")).toBe("context"); + expect(getWorkspaceChangeLineClass("+++ b/file.md")).toBe("meta"); + expect(getWorkspaceChangeLineClass("--- a/file.md")).toBe("meta"); + }); + + test("treats content lines beginning with +++/--- as add/remove, not meta", () => { + expect(getWorkspaceChangeLineClass("+++foo")).toBe("addition"); + expect(getWorkspaceChangeLineClass("---bar")).toBe("deletion"); + }); +});