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");
+ });
+});