mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
feat: add workspace change review for agent runs (#3945)
* feat: add workspace change review * chore: format workspace change files * fix: optimize workspace change summaries * style: refine workspace change badge scale * fix: restore workspace change user context import * fix(frontend): gate workspace change badge to assistant messages Only pass run_id to assistant MessageListItems so the workspace-change badge can never render under a user's prompt, which carries the same run_id. Assert single badge render in the E2E flow. * fix(workspace-changes): address review feedback on diff parsing and badge - Restrict unified-diff header detection to "+++ "/"--- " (trailing space) in both backend _count_diff_lines and frontend getWorkspaceChangeLineClass so content lines beginning with +++/--- are counted/styled correctly - Gate WorkspaceChangeBadge to ai messages so tool messages folded into an assistant group don't render a duplicate badge - Add regression tests for both diff-classification fixes - Apply prettier formatting to workspace-changes files flagged by CI --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
f6a910dec9
commit
00161811bd
29 changed files with 2194 additions and 10 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 (`<think>...</think>`, 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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
75
backend/packages/harness/deerflow/workspace_changes/api.py
Normal file
75
backend/packages/harness/deerflow/workspace_changes/api.py
Normal file
|
|
@ -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
|
||||
196
backend/packages/harness/deerflow/workspace_changes/diff.py
Normal file
196
backend/packages/harness/deerflow/workspace_changes/diff.py
Normal file
|
|
@ -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
|
||||
111
backend/packages/harness/deerflow/workspace_changes/recorder.py
Normal file
111
backend/packages/harness/deerflow/workspace_changes/recorder.py
Normal file
|
|
@ -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)
|
||||
248
backend/packages/harness/deerflow/workspace_changes/scanner.py
Normal file
248
backend/packages/harness/deerflow/workspace_changes/scanner.py
Normal file
|
|
@ -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
|
||||
107
backend/packages/harness/deerflow/workspace_changes/types.py
Normal file
107
backend/packages/harness/deerflow/workspace_changes/types.py
Normal file
|
|
@ -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(),
|
||||
}
|
||||
445
backend/tests/test_workspace_changes.py
Normal file
445
backend/tests/test_workspace_changes.py
Normal file
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
1
frontend/src/components/workspace/changes/index.ts
Normal file
1
frontend/src/components/workspace/changes/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./workspace-change-badge";
|
||||
|
|
@ -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 (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<div className="border-border/70 bg-muted/20 mt-3 overflow-hidden rounded-xl border">
|
||||
<div className="border-border/70 flex items-center justify-between gap-3 border-b p-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="bg-background/80 flex size-10 shrink-0 items-center justify-center rounded-lg">
|
||||
<FileDiffIcon className="text-muted-foreground size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground text-sm font-semibold">
|
||||
{t.workspaceChanges.editedTitle(count)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground mt-0.5 inline-flex items-center gap-1 text-xs font-medium transition-colors"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{t.workspaceChanges.viewChanges}
|
||||
<ArrowUpRightIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SummaryDelta
|
||||
additions={data.summary.additions}
|
||||
deletions={data.summary.deletions}
|
||||
className="hidden text-xs font-semibold sm:inline-flex"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{t.workspaceChanges.loading}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading &&
|
||||
files.map((file) => (
|
||||
<WorkspaceChangeSummaryRow
|
||||
key={`${file.status}:${file.path}`}
|
||||
file={file}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<WorkspaceChangePanel
|
||||
threadId={threadId}
|
||||
runId={runId}
|
||||
fallbackSummary={data.summary}
|
||||
open={open}
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceChangeSummaryRow({ file }: { file: WorkspaceFileChange }) {
|
||||
const pathParts = formatWorkspacePath(file.path);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0 truncate text-sm" title={file.path}>
|
||||
{pathParts.dirname && (
|
||||
<span className="text-muted-foreground">{pathParts.dirname}/</span>
|
||||
)}
|
||||
<span className="text-foreground font-medium">
|
||||
{pathParts.basename}
|
||||
</span>
|
||||
</div>
|
||||
<SummaryDelta
|
||||
additions={file.additions}
|
||||
deletions={file.deletions}
|
||||
className="text-sm font-semibold"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryDelta({
|
||||
additions,
|
||||
deletions,
|
||||
className,
|
||||
}: {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1 tabular-nums",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="text-emerald-500">+{additions}</span>
|
||||
<span className="text-red-500">-{deletions}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<SheetContent className="w-[min(92vw,900px)] gap-0 p-0 sm:max-w-[900px]">
|
||||
<SheetHeader className="border-border border-b px-5 py-4">
|
||||
<SheetTitle className="flex items-center gap-2 text-base">
|
||||
<FileDiffIcon className="text-muted-foreground size-4" />
|
||||
{t.workspaceChanges.title}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t.workspaceChanges.badge(
|
||||
fileCount,
|
||||
changes.summary.additions,
|
||||
changes.summary.deletions,
|
||||
)}
|
||||
{changes.summary.truncated
|
||||
? ` · ${t.workspaceChanges.truncatedSummary}`
|
||||
: ""}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
{isLoading && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.workspaceChanges.loading}
|
||||
</p>
|
||||
)}
|
||||
{!isLoading && files.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.workspaceChanges.noChanges}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
{files.map((file) => (
|
||||
<WorkspaceChangeFile
|
||||
key={`${file.status}:${file.path}`}
|
||||
file={file}
|
||||
threadId={threadId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Collapsible defaultOpen={hasDiff}>
|
||||
<div className="border-border/70 bg-background rounded-lg border">
|
||||
<CollapsibleTrigger className="flex w-full items-start justify-between gap-3 px-3 py-2 text-left">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<StatusIcon status={file.status} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground truncate font-mono text-xs">
|
||||
{file.path}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span>{statusLabel(file.status, t)}</span>
|
||||
{(file.additions > 0 || file.deletions > 0) && (
|
||||
<span>
|
||||
<span className="text-emerald-500">+{file.additions}</span>{" "}
|
||||
<span className="text-red-500">-{file.deletions}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{canOpenFile && (
|
||||
<a
|
||||
href={openUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors"
|
||||
title={t.workspaceChanges.openFile}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{hasDiff ? (
|
||||
<WorkspaceDiff diff={file.diff} />
|
||||
) : (
|
||||
<div className="border-border/70 text-muted-foreground border-t px-3 py-3 text-xs">
|
||||
{unavailableLabel(file.diff_unavailable_reason, t)}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceDiff({ diff }: { diff: string }) {
|
||||
return (
|
||||
<pre className="border-border/70 bg-muted/30 max-h-[520px] overflow-auto border-t p-0 font-mono text-xs leading-5">
|
||||
{diff.split("\n").map((line, index) => (
|
||||
<div
|
||||
key={`${index}:${line}`}
|
||||
className={cn(
|
||||
"min-w-max px-3 whitespace-pre",
|
||||
diffLineClassName(line),
|
||||
)}
|
||||
>
|
||||
{line || " "}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: WorkspaceChangeStatus }) {
|
||||
const className = "mt-0.5 size-4 shrink-0";
|
||||
if (status === "created") {
|
||||
return <FilePlusIcon className={cn(className, "text-emerald-500")} />;
|
||||
}
|
||||
if (status === "deleted") {
|
||||
return <FileMinusIcon className={cn(className, "text-red-500")} />;
|
||||
}
|
||||
return <FilePenLineIcon className={cn(className, "text-sky-500")} />;
|
||||
}
|
||||
|
||||
function statusLabel(
|
||||
status: WorkspaceChangeStatus,
|
||||
t: ReturnType<typeof useI18n>["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<typeof useI18n>["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";
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
<CitationSourcesPanel sources={citationSources} />
|
||||
{message.type === "ai" && (
|
||||
<WorkspaceChangeBadge
|
||||
threadId={threadId}
|
||||
runId={runId}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
</AIElementMessageContent>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: "今天我能为你做些什么?",
|
||||
|
|
|
|||
34
frontend/src/core/workspace-changes/api.ts
Normal file
34
frontend/src/core/workspace-changes/api.ts
Normal file
|
|
@ -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<WorkspaceChangesResponse> {
|
||||
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();
|
||||
}
|
||||
57
frontend/src/core/workspace-changes/hooks.ts
Normal file
57
frontend/src/core/workspace-changes/hooks.ts
Normal file
|
|
@ -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<WorkspaceChangesResponse>({
|
||||
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,
|
||||
});
|
||||
}
|
||||
4
frontend/src/core/workspace-changes/index.ts
Normal file
4
frontend/src/core/workspace-changes/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./api";
|
||||
export * from "./hooks";
|
||||
export * from "./summary";
|
||||
export * from "./types";
|
||||
54
frontend/src/core/workspace-changes/summary.ts
Normal file
54
frontend/src/core/workspace-changes/summary.ts
Normal file
|
|
@ -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<WorkspaceFileChange["status"], number>;
|
||||
|
||||
return [...files].sort((left, right) => {
|
||||
const rankDiff = statusRank[left.status] - statusRank[right.status];
|
||||
if (rankDiff !== 0) {
|
||||
return rankDiff;
|
||||
}
|
||||
return left.path.localeCompare(right.path);
|
||||
});
|
||||
}
|
||||
41
frontend/src/core/workspace-changes/types.ts
Normal file
41
frontend/src/core/workspace-changes/types.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
123
frontend/tests/e2e/workspace-changes.spec.ts
Normal file
123
frontend/tests/e2e/workspace-changes.spec.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
50
frontend/tests/unit/core/workspace-changes/api.test.ts
Normal file
50
frontend/tests/unit/core/workspace-changes/api.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
49
frontend/tests/unit/core/workspace-changes/summary.test.ts
Normal file
49
frontend/tests/unit/core/workspace-changes/summary.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue